openapi: 3.1.0
info:
  title: TaxHub API
  version: "1.0"
  description: |
    Multi-tenant US sales tax rate API. Authenticate with
    `Authorization: Bearer <api_key>` — `thk_live_…` keys are billed per
    call, `thk_test_…` sandbox keys never are.

    **Every response** carries an `X-Request-Id` header (also in error bodies
    and rate responses as `request_id`); quote it when contacting support.
    Every request an API key made in the last 30 days, with what was sent and
    what TaxHub answered, is in the dashboard under Developers → Request log
    (search by request id, Idempotency-Key or path). Street lines, names,
    emails, phone numbers and keys are removed before a request is stored.

    **Limits.** Each key has a per-minute rate limit (sliding window, shared
    across all of TaxHub's servers); over it you get `429 rate_limited` with
    `Retry-After`. If your account has a monthly spend cap, reaching it
    returns `429 quota_exceeded` until the cap is raised or the month ends.
    Neither 429 is billed.

    **Scopes.** Each key carries `rates:read` (rate lookups, categories),
    `calculations:write` (order calculations), `transactions:write` (commit,
    revise, void and refund transactions), `transactions:read` and/or
    `usage:read`; a call outside its key's scopes gets `403
    insufficient_scope`. Keys created before November 2026 lack the
    transactions scopes: create a key for them.

    **Retries.** Writes to `/v1/transactions` take an `Idempotency-Key`
    header (any string up to 255 characters, e.g. a UUID): a retry with the
    same key gets the first answer back, with `Idempotent-Replayed: true`,
    and writes nothing again. Keys are kept 24 hours. Without one, posting
    an identical transaction again also replays; different content under the
    same `transaction_id` is `409 conflict`.

    **IP allowlists.** A key can be restricted to IP addresses / CIDR ranges;
    requests from elsewhere get `403 ip_not_allowed` (not billed, and not
    counted against the key's rate limit). Revoking a
    key or changing its allowlist takes effect on its next request.

    TaxHub provides tax rate data, not tax advice; accuracy depends on data
    sources and tenants are responsible for their own tax compliance.
servers:
  - url: https://app.taxhub.tax
security:
  - bearerAuth: []
paths:
  /v1/rates:
    get:
      summary: Rate lookup by location (optionally item-aware)
      parameters:
        - { name: country, in: query, schema: { type: string, enum: [US] }, description: "US only in v1" }
        - { name: zip, in: query, required: true, schema: { type: string, pattern: '^\d{5}(-\d{4})?$' } }
        - { name: state, in: query, schema: { type: string } }
        - { name: city, in: query, schema: { type: string } }
        - { name: street, in: query, schema: { type: string }, description: "Full address strongly encouraged" }
        - { name: category_code, in: query, schema: { type: string }, description: "Product tax code. Food (40030) in a state with its own food rates is answered at them: combined_rate, breakdown and jurisdictions are the food rates" }
        - { name: item_code, in: query, schema: { type: string }, description: "Your SKU, resolved via your product mappings (dashboard). An unmapped SKU uses your default category if you set one, with a warning; otherwise the base rate is returned with a warning." }
        - { name: on_date, in: query, schema: { type: string, format: date }, description: "Serve the rate version effective on this date. Default: today in the account's time zone (dashboard settings; from the ship-from state when not set), never the UTC date." }
        - { name: as_of, in: query, schema: { type: string, format: date-time }, description: "What TaxHub knew at this moment (not in the future): an official answer uses the rates and boundary records as they were recorded then, to reproduce an earlier answer. Default: now. The other stored rates keep one clock, so for them it is ignored, with a warning." }
        - { name: fallback, in: query, schema: { type: string, enum: [earliest_known] } }
      responses:
        "200":
          description: Resolved rate
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RateResponse" }
        "401": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/Error" }
  /v1/rates/batch:
    post:
      summary: Batch rate lookup (up to 100; each item bills as one call)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              maxItems: 100
              items: { $ref: "#/components/schemas/LookupRequest" }
      responses:
        "200":
          description: Results in request order; per-item errors inline
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  results:
                    type: array
                    items:
                      oneOf:
                        - { $ref: "#/components/schemas/RateResponse" }
                        - { $ref: "#/components/schemas/ErrorBody" }
        "401": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/Error" }
  /v1/calculations:
    post:
      summary: Calculate an order's tax (one billable call per calculation)
      description: |
        Prices every line, applies product category rules (exempt, reduced,
        exempt below a price threshold) and discounts, taxes shipping and
        handling, and honours customer exemptions. Stores nothing. Needs the
        `calculations:write` scope.

        **Cells.** The answer's unit is the cell: one line (or one shipping or
        handling portion) × one jurisdiction (state, county, city, special
        districts), with the jurisdiction's id, name and Census GEOID, its
        rate, the taxable amount, the tax and the tax before rounding
        (`tax_exact`). Document tax = Σ lines = Σ cells, always;
        `jurisdictions` sums the cells per jurisdiction and `breakdown` per
        level.

        **Rounding** follows your account's policy (dashboard → Tax
        settings): Match Shopify by default (each line and each jurisdiction
        rounded half-up), Lowest total (the order once at the combined rate)
        or Legacy (each line at the combined rate), or a custom basis,
        granularity and mode. Texas always rounds the whole sale at the
        combined rate (34 TAC 3.286). `rounding` says what was applied.

        **Sourcing.** An in-state sale in an origin-based state (AZ, IL, MO,
        MS, OH, TN, TX, UT, VA) is taxed at the ship-from address; an
        in-state California sale takes state, county and city tax from the
        ship-from address and district tax from the delivery address. Every
        other sale is taxed where it is delivered. The ship-from address is
        `from`, one of your locations (`ship_from_site_id`), or your
        dashboard's default location when neither is sent (`from: null`
        uses none). A line can have its own `ship_to` and
        `ship_from_site_id`: lines with the same addresses form a tax area
        (`tax_areas`), each sourced and looked up on its own (at most 10).
        When a delivery ZIP is new to TaxHub and its state isn't sent,
        TaxHub looks the ZIP up to learn its state.

        **Shipping** follows the goods: it is split across the sale lines by
        value and each share is taxed like its line, so exempt goods ship
        tax-free. Whether delivery is taxable depends on the destination
        state: always with taxable goods in states such as NY, TX and MN;
        in states such as CA, FL and IL only when your shipping facts
        (dashboard → Tax profile → Shipping) fail the state's conditions or
        you recorded a "taxable" override; never when the buyer arranged and
        pays the carrier (`shipping.buyer_arranged_third_party_carrier`).
        **Handling** is taxable when the goods are.

        **Lines and customers.** Gift cards, tips, donations, fees and
        deposits (`kind`) are kept but aren't taxed or counted as sales. A
        discount funded by a third party (`discount_funded_by:
        third_party`, e.g. a manufacturer's coupon) doesn't lower the taxable
        price. Send `customer_id` (the customer's id in your system) and its
        exemption grants apply per state, with their reason;
        `exemption_type` is the older all-or-nothing switch.

      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CalculationRequest" }
            example:
              to: { zip: "10541", state: "NY", city: "Mahopac", street: "1 Main St" }
              lines:
                - { id: "1", item_code: "TSHIRT-M", quantity: 2, unit_price: 24.99 }
                - { category_code: "81100", quantity: 1, unit_price: 18.00, discount: 3.00 }
              shipping: 7.95
      responses:
        "200":
          description: The order's tax
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CalculationResponse" }
        "401": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/Error" }
  /v1/transactions:
    post:
      summary: Commit a sale to the ledger (one billable call)
      description: |
        Records an invoice in TaxHub's ledger: the transaction, its lines and
        its cells (line × jurisdiction), with tax collected = TaxHub's
        calculated tax. Either commit a calculation you made
        (`calculation_id` from `POST /v1/calculations`, made for the same
        tax date), or send the order itself (the same fields as a
        calculation) and it is calculated on the tax date (`tax_date`,
        default `document_date`) and committed. Posting the same document
        again replays (200, `replayed: true`); different content under the
        same `transaction_id` is `409 conflict` (send a new version with
        PUT). An order from a channel (`channel` + `external_order_id`) can
        be only one sale. Needs `transactions:write`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TransactionCommit" }
            example:
              transaction_id: "INV-1042"
              document_date: "2026-11-10"
              channel: zarmoney
              external_order_id: "1042"
              calculation_id: "6b0c2f0e-6c1a-4c2e-9a77-1c1a4e5c9f10"
      responses:
        "201": { $ref: "#/components/responses/TransactionWritten" }
        "200": { $ref: "#/components/responses/TransactionWritten" }
        "401": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/transactions/import:
    post:
      summary: Import an order whose tax another platform collected (one billable call)
      description: |
        Stores the platform's tax as **collected**, never recalculated, and
        TaxHub's own calculation of the same order as **calculated**, so the
        difference is classified (`variance_class`: a figure one of the four
        lawful rounding methods produces is `method`, not `under` or
        `over_material`). Each tax line is mapped to a jurisdiction: your tax
        line map (channel, state, title) first, then TaxHub's own
        jurisdiction for that line at the title's level with the rate within
        0.01%, else it's booked at state level (`quarantined`) with an open
        exception: the money is never lost. If the lines' tax doesn't add up
        to `total_tax` (or a title's order total), nothing is committed and
        the order waits as an exception (422 with `exception_id`). Send
        `format: shopify` with the Admin GraphQL `order` to import a Shopify
        order as is (its `ratePercentage`, never `rate`). Repeating an import
        replays. Needs `transactions:write`.

        A tax line can name TaxHub's jurisdiction (`jurisdiction_id`, from a
        calculation cell; ZarMoney's TaxItem): it maps to that jurisdiction
        first (`mapping: jurisdiction_id`); an id TaxHub doesn't know, or one
        in another state, is quarantined like an unmapped title.

        **Fallback contract (ZarMoney).** When TaxHub can't be reached (a
        network error or timeout, a 5xx, or a 429 still refused after its
        `Retry-After`), ZarMoney taxes the invoice with its own engine (its
        TaxItem rates, from the cells TaxHub returned before) and invoices
        without waiting. Once calls succeed again, it posts each such order
        here with `source: zarmoney_fallback`, `channel: zarmoney`, its
        invoice number as `transaction_id` (and in the `Idempotency-Key`),
        the invoice's `document_date`, and tax lines naming the TaxItems by
        `jurisdiction_id`. TaxHub stores ZarMoney's cents as collected
        (never recalculated), calculates its own figure on the invoice's tax
        date, and classifies the difference. `/v1/health` only says the
        service is up; resume on a call that succeeds.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ImportRequest" }
      responses:
        "201":
          description: Imported (200 when the same import was already stored)
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  replayed: { type: boolean }
                  calculation_id: { type: [string, "null"], description: "TaxHub's calculation of the order" }
                  mappings:
                    type: array
                    items:
                      type: object
                      properties:
                        line_id: { type: string }
                        title: { type: string }
                        mapping: { type: string, enum: [jurisdiction_id, exact_map, engine_candidate, quarantined] }
                        jurisdiction_id: { type: [string, "null"] }
                        level: { type: [string, "null"] }
                  exceptions: { type: array, items: { type: object, properties: { reason: { type: string }, detail: { type: string } } } }
                  transaction: { $ref: "#/components/schemas/Transaction" }
        "409": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
  /v1/transactions/batch:
    post:
      summary: Up to 50 commits or imports in one request
      description: |
        `transactions` is a list of commit bodies (as POST /v1/transactions)
        or import bodies with `operation: import`. Each is answered on its own
        in `results` (its status, or its error); one failing doesn't stop the
        others. Each item TaxHub answered is a billable call (a refusal too);
        an item that failed on TaxHub's side (5xx) isn't billed, and if every
        item did, the batch answers 500. Idempotency-Key covers the whole
        batch, but isn't kept when an item failed on TaxHub's side: retry the
        batch with the same key, and what was committed replays. Needs
        `transactions:write`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [transactions]
              properties:
                transactions:
                  type: array
                  minItems: 1
                  maxItems: 50
                  items: { type: object, description: "A commit body, or an import body with operation: import" }
      responses:
        "200":
          description: One result per item
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  committed: { type: integer }
                  failed: { type: integer }
                  results:
                    type: array
                    items:
                      type: object
                      properties:
                        index: { type: integer }
                        operation: { type: string, enum: [commit, import] }
                        status: { type: integer }
                        transaction_id: { type: [string, "null"] }
                        replayed: { type: boolean }
                        version: { type: integer }
                        totals: { type: object }
                        error: { type: object, properties: { code: { type: string }, message: { type: string } } }
        "422": { $ref: "#/components/responses/Error" }
  /v1/transactions/{transaction_id}:
    parameters:
      - $ref: "#/components/parameters/TransactionId"
    get:
      summary: A transaction as stored (current version, or ?version=n)
      description: Needs `transactions:read`.
      security:
        - bearerAuth: []
      parameters:
        - { name: version, in: query, schema: { type: integer, minimum: 1 } }
      responses:
        "200":
          description: The transaction
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  transaction: { $ref: "#/components/schemas/Transaction" }
        "404": { $ref: "#/components/responses/Error" }
    put:
      summary: A new version of a sale (the old one is kept as superseded)
      description: |
        Same body as POST without `transaction_id`. Not for a voided sale, a
        sale in a filed return, or a sale with refunds (refund or credit it
        instead). Needs `transactions:write`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TransactionRevision" }
      responses:
        "200": { $ref: "#/components/responses/TransactionWritten" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
  /v1/transactions/{transaction_id}/void:
    parameters:
      - $ref: "#/components/parameters/TransactionId"
    post:
      summary: Void a transaction (it stays in the ledger as voided)
      description: Reports leave it out. Voiding again replays. A sale with refunds is voided after them. Needs `transactions:write`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        content:
          application/json:
            schema: { type: object, properties: { reason: { type: string, maxLength: 500 } } }
      responses:
        "200": { $ref: "#/components/responses/TransactionWritten" }
        "404": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
  /v1/transactions/{transaction_id}/refunds:
    parameters:
      - $ref: "#/components/parameters/TransactionId"
    post:
      summary: Refund a sale
      description: |
        A refund is its own transaction (`refund_id`) on the sale's tax date
        and period; its document date is the refund's. Each refunded line
        returns its share of the tax the sale collected (pro rata on the
        line's price before tax, rounded half-up per jurisdiction), never a
        recalculation at today's rate; the rest of a line comes back exactly.
        Without `lines`, `shipping` or `handling`, everything not refunded
        yet is refunded. Refunds can't return more than the sale had.
        Sending the same `refund_id` again replays. Needs `transactions:write`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RefundRequest" }
            example:
              refund_id: "CM-77"
              document_date: "2026-11-20"
              lines: [{ line_id: "1", amount: 24.99 }]
              shipping: 5.00
      responses:
        "201": { $ref: "#/components/responses/TransactionWritten" }
        "200": { $ref: "#/components/responses/TransactionWritten" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
  /v1/documents:
    post:
      summary: Store a quote or sales order with its estimated tax (one billable call when calculated now)
      description: |
        A document is your quote or sales order with TaxHub's estimated tax
        (gaps G3). Send a calculation you made (`calculation_id`) or the
        order itself (the same fields as a calculation), calculated on
        `tax_date` (default `document_date`). A quote is valid until
        `expires_on` (default 30 days after its date; `null` for none); a
        sales order can say when it's expected to be invoiced
        (`expected_on`). A sales order made from a quote names it
        (`converted_from`) and the quote becomes `converted`. Posting the
        same document again replays (200); different content under an
        existing `document_id` is `409 conflict` (revise it with PUT).
        Invoices are transactions that name it (`source_document_id`, and
        `lines[].source_line_id`). The answer lists the rate changes known
        to reach it before it's likely to be invoiced (`rate_changes`).
        Needs `transactions:write`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DocumentRequest" }
            example:
              document_id: "SO-1042"
              type: sales_order
              document_date: "2026-10-20"
              expected_on: "2027-01-15"
              converted_from: "Q-2101"
              channel: zarmoney
              external_order_id: "1042"
              to: { street: "250 Marquette Ave", city: "Minneapolis", state: "MN", zip: "55401" }
              lines: [{ id: "OAK-48", item_code: "OAK-48", quantity: 48, unit_price: 200 }]
      responses:
        "201": { $ref: "#/components/responses/DocumentWritten" }
        "200": { $ref: "#/components/responses/DocumentWritten" }
        "401": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      summary: Your quotes and sales orders, newest change first
      description: >-
        Each page looks at up to 1,000 documents, so with a status or
        `affected` filter a page can hold fewer rows than `limit`, even none,
        while `has_more` is true: follow `next_cursor`. Needs
        `transactions:read`.
      security:
        - bearerAuth: []
      parameters:
        - { name: type, in: query, schema: { type: string, enum: [quote, sales_order] } }
        - name: status
          in: query
          description: A status, or `active` (open or partially invoiced)
          schema: { type: string, enum: [active, open, partially_invoiced, invoiced, expired, converted, cancelled, closed] }
        - { name: affected, in: query, description: "true: only those with a rate change ahead", schema: { type: boolean } }
        - { name: q, in: query, description: "Part of the document id or external order id", schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 50 } }
        - { name: cursor, in: query, description: "next_cursor from the previous page", schema: { type: string } }
      responses:
        "200":
          description: A page of documents
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  documents:
                    type: array
                    items:
                      type: object
                      properties:
                        document_id: { type: string }
                        type: { type: string, enum: [quote, sales_order] }
                        status: { $ref: "#/components/schemas/DocumentStatus" }
                        version: { type: integer }
                        document_date: { type: string, format: date }
                        tax_date: { type: string, format: date }
                        expires_on: { type: [string, "null"], format: date }
                        expected_on: { type: [string, "null"], format: date }
                        totals: { type: object, properties: { amount: { type: number }, taxable_amount: { type: number }, tax: { type: number } } }
                        invoices: { type: integer }
                        rate_changes: { type: integer, description: "Rate changes known to reach it before it's likely to be invoiced" }
                        next_change_on: { type: [string, "null"], format: date }
                        estimated_difference: { type: [number, "null"], description: "Their estimated effect on its tax" }
                        updated_at: { type: string, format: date-time }
                  has_more: { type: boolean }
                  next_cursor: { type: [string, "null"] }
  /v1/documents/{document_id}:
    parameters:
      - $ref: "#/components/parameters/DocumentId"
    get:
      summary: A quote or sales order (current version, or ?version=n), with its invoices and the rate changes ahead of it
      description: Needs `transactions:read`.
      security:
        - bearerAuth: []
      parameters:
        - { name: version, in: query, schema: { type: integer, minimum: 1 } }
      responses:
        "200":
          description: The document
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  document: { $ref: "#/components/schemas/Document" }
        "404": { $ref: "#/components/responses/Error" }
    put:
      summary: A new version of a quote or sales order (the old ones are kept)
      description: |
        Same body as POST without `document_id` (`type` may be left out).
        `expires_on` and `expected_on` carry over unless sent. Not for a
        converted, cancelled or closed document (409). Needs
        `transactions:write`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DocumentRequest" }
      responses:
        "200": { $ref: "#/components/responses/DocumentWritten" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
  /v1/documents/{document_id}/cancel:
    parameters:
      - $ref: "#/components/parameters/DocumentId"
    post:
      summary: Cancel a quote or sales order nothing was invoiced from
      description: A document with invoices is closed instead (409). Cancelling again replays. Needs `transactions:write`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        content:
          application/json:
            schema: { type: object, properties: { reason: { type: string, maxLength: 500 } } }
      responses:
        "200": { $ref: "#/components/responses/DocumentWritten" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
  /v1/documents/{document_id}/close:
    parameters:
      - $ref: "#/components/parameters/DocumentId"
    post:
      summary: Close a quote or sales order (what's left on it won't be invoiced)
      description: Closing again replays. Needs `transactions:write`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        content:
          application/json:
            schema: { type: object, properties: { reason: { type: string, maxLength: 500 } } }
      responses:
        "200": { $ref: "#/components/responses/DocumentWritten" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
  /v1/categories:
    get:
      summary: List product tax categories
      responses:
        "200":
          description: Categories
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  categories:
                    type: array
                    items:
                      type: object
                      properties:
                        code: { type: string }
                        name: { type: string }
                        description: { type: string }
        "401": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/usage:
    get:
      summary: Tenant's own metered usage
      parameters:
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to, in: query, schema: { type: string, format: date } }
        - { name: group_by, in: query, schema: { type: string, enum: [day, endpoint, key], default: day } }
      responses:
        "200":
          description: Aggregated usage
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  usage:
                    type: array
                    items:
                      type: object
                      properties:
                        group: { type: string }
                        calls: { type: integer }
                        billable: { type: integer }
                        provider_calls: { type: integer }
                        errors: { type: integer }
        "401": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/health:
    get:
      summary: Liveness (unauthenticated, never billed)
      security: []
      responses:
        "200":
          description: OK
  /v1/jurisdictions:
    get:
      summary: TaxHub jurisdictions by id (ZarMoney's TaxItems)
      description: |
        Up to 100 ids, comma-separated. A jurisdiction's id is stable for its
        life (a rename or a new agency code changes its attributes, not its
        id), so it is ZarMoney's TaxItem. Ids TaxHub doesn't know come back
        in `missing`. The rate for a date comes with each calculation cell
        today; a dated rate feed (TaxItemRate) follows when rates are
        versioned per jurisdiction. One billable call per request. Needs
        `rates:read`.
      security:
        - bearerAuth: []
      parameters:
        - { name: ids, in: query, required: true, schema: { type: string }, description: "Jurisdiction ids, comma-separated (at most 100)" }
      responses:
        "200":
          description: The jurisdictions, in the order asked
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  jurisdictions: { type: array, items: { $ref: "#/components/schemas/Jurisdiction" } }
                  missing: { type: array, items: { type: string } }
        "422": { $ref: "#/components/responses/Error" }
  /v1/jurisdictions/{id}:
    get:
      summary: One TaxHub jurisdiction (a ZarMoney TaxItem)
      description: Needs `rates:read`.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: The jurisdiction
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  jurisdiction: { $ref: "#/components/schemas/Jurisdiction" }
        "404": { $ref: "#/components/responses/Error" }
  /v1/jurisdictions/{id}/rates:
    get:
      summary: A jurisdiction's dated rates (ZarMoney's TaxItemRate)
      description: |
        Needs `rates:read`. TaxHub's own rate history for the jurisdiction,
        from official sources only (state and Streamlined files, state
        notices, staff corrections citing one, reconstructed history), never
        from TaxJar. It fills as official data is loaded state by state, so a
        jurisdiction can have no versions yet. `effective_from` is
        inclusive and `effective_to` exclusive (null = open-ended);
        `rate_percent` has four decimals, exactly as ZarMoney's TaxRate
        stores it. `as_of` shows what TaxHub believed at a moment (a
        correction recorded later doesn't change that answer).
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: from, in: query, schema: { type: string, format: date }, description: "Versions overlapping [from, to)" }
        - { name: to, in: query, schema: { type: string, format: date } }
        - { name: on_date, in: query, schema: { type: string, format: date }, description: "Only the versions in force on this tax date (instead of from/to)" }
        - { name: as_of, in: query, schema: { type: string, format: date-time }, description: "What TaxHub believed at this moment (default now)" }
        - { name: tax_type, in: query, schema: { type: string, enum: [sales, sales_interstate, use, food_drug, food_drug_interstate, lodging, other] }, description: "sales = the general rate; *_interstate = the rate for sales from outside the state (SST states give all four)" }
      responses:
        "200":
          description: The versions, by tax type and date
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  jurisdiction_id: { type: string, format: uuid }
                  as_of: { type: string, format: date-time }
                  rates:
                    type: array
                    items:
                      type: object
                      properties:
                        rate_version_id: { type: string, format: uuid }
                        tax_type: { type: string }
                        rate_percent: { type: string, example: "7.2500" }
                        rate_millionths: { type: integer, example: 72500 }
                        effective_from: { type: string, format: date }
                        effective_to: { type: [string, "null"], format: date }
                        change_kind: { type: string, enum: [new, increase, decrease, unchanged, sunset, extension, correction, code_reissue, split] }
                        confidence: { type: string, enum: [official, official_future, manual, reconstructed] }
                        source:
                          type: object
                          properties:
                            code: { type: string }
                            kind: { type: string }
                            reference: { type: [string, "null"], description: "The notice number or URL" }
                            published_at: { type: [string, "null"], format: date }
                        recorded_at: { type: string, format: date-time }
        "404": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
  /v1/platform/tenants:
    post:
      summary: A partner creates a company's tenant (ZarMoney's "TaxHub app" install)
      description: |
        For platform partners (ZarMoney), with a platform credential
        (`Authorization: Bearer thp_…`, issued by TaxHub staff). Once per
        `account_id`: the first call creates the tenant on the standard plan
        with a live and a sandbox API key (every scope), shown once in
        `keys`; a repeat returns the tenant with `created: false` and no keys
        (issue new ones with `POST /v1/platform/tenants/{account_id}/keys`).
        People sign in to the tenant's dashboard through `sso_url`.
      security:
        - platformAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [account_id, name]
              properties:
                account_id: { type: [string, integer], description: "Your id for the company (ZarMoney: the company id); at most 200 printable characters" }
                name: { type: string, maxLength: 100, description: The company's legal name (one tenant = one legal entity) }
                time_zone: { type: [string, "null"], description: "IANA zone for tax dates, e.g. America/Los_Angeles" }
      responses:
        "201":
          description: Created, with its keys (shown once)
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  created: { type: boolean, const: true }
                  tenant: { $ref: "#/components/schemas/PlatformTenant" }
                  keys:
                    type: object
                    properties:
                      live: { $ref: "#/components/schemas/IssuedKey" }
                      sandbox: { $ref: "#/components/schemas/IssuedKey" }
        "200":
          description: The account already has a tenant (no keys)
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  created: { type: boolean, const: false }
                  tenant: { $ref: "#/components/schemas/PlatformTenant" }
                  keys: { type: "null" }
                  message: { type: string }
        "401": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
  /v1/platform/tenants/{account_id}:
    get:
      summary: The tenant of one of the partner's accounts, with its active keys (prefixes only)
      security:
        - platformAuth: []
      parameters:
        - { name: account_id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The tenant
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  tenant: { $ref: "#/components/schemas/PlatformTenant" }
                  keys:
                    type: array
                    items:
                      type: object
                      properties:
                        name: { type: string }
                        prefix: { type: string }
                        environment: { type: string, enum: [live, sandbox] }
                        scopes: { type: array, items: { type: string } }
                        created_at: { type: string, format: date-time }
                        last_used_at: { type: [string, "null"], format: date-time }
                        expires_at: { type: [string, "null"], format: date-time }
        "404": { $ref: "#/components/responses/Error" }
  /v1/platform/tenants/{account_id}/keys:
    post:
      summary: New API keys for one of the partner's tenants (shown once)
      description: |
        A new live and sandbox key (or `environment: live | sandbox` for one).
        Older keys keep working until an admin of the tenant revokes them in
        the dashboard.
      security:
        - platformAuth: []
      parameters:
        - { name: account_id, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                environment: { type: string, enum: [live, sandbox, both], default: both }
      responses:
        "201":
          description: The new keys
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  keys:
                    type: object
                    properties:
                      live: { $ref: "#/components/schemas/IssuedKey" }
                      sandbox: { $ref: "#/components/schemas/IssuedKey" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
  /sso/{partner}:
    post:
      summary: Single sign-on to the dashboard with a partner's signed token (browser)
      description: |
        The partner's page sends the person's browser here (an auto-submitted
        form, or `GET ?token=`; POST keeps the token out of logs and history).
        The token is a JWT signed with the partner's **private** key, RS256
        or ES256, with `kid` naming one of the public keys TaxHub staff added
        for the partner. Claims: `iss` (the partner's issuer, ZarMoney:
        `zarmoney`), `aud` = `taxhub`, `sub` (the partner's id for the
        person), `email`, `account_id` (the company), `role` (`owner`,
        `admin` or `viewer`), `jti` (unique: each token works once), `iat`
        and `exp` at most 5 minutes apart, optional `name`.

        The first sign-in adds the person to the company's tenant; later ones
        update their role from the token. Staff accounts, members of another
        tenant and people an owner disabled in TaxHub are refused. Signed in,
        the browser lands on `next` (a dashboard path) or the Tax profile; a
        refusal lands on the dashboard's sign-in page with the reason.
      parameters:
        - { name: partner, in: path, required: true, schema: { type: string, example: zarmoney } }
      requestBody:
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
                next: { type: string, example: /dashboard/profile/registrations }
      responses:
        "303":
          description: To a one-time sign-in link (then the dashboard), or to the sign-in page with the reason
webhooks:
  # Configured in the dashboard (Webhooks tab). TaxHub POSTs these to your
  # endpoint; any 2xx within 10 seconds counts as delivered.
  rates.changed:
    post:
      summary: The rate served for a location you use changed
      description: |
        Sent when the rate TaxHub serves your account for a location your API
        calls used in the last 12 months changed — a manual or override
        entry, an import or its rollback, a provider refresh, or a scheduled
        change taking effect. For official answers (states TaxHub answers
        from state data) the location is a tax area: a change to any of its
        jurisdictions' rates (a state file, a staff correction citing a
        notice, a scheduled change taking effect) or a jurisdiction joining
        or leaving it. Up to 100 changes per event. Changes you aren't
        served (for example a global rate behind your own override) send
        nothing.
      parameters: &webhookHeaders
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookEventId"
        - $ref: "#/components/parameters/WebhookEventType"
        - $ref: "#/components/parameters/WebhookDeliveryId"
        - $ref: "#/components/parameters/WebhookDeliveryAttempt"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEvent"
                - type: object
                  properties:
                    type: { const: rates.changed }
                    data:
                      type: object
                      properties:
                        count: { type: integer }
                        changes:
                          type: array
                          maxItems: 100
                          items: { $ref: "#/components/schemas/RateChanged" }
      responses: &webhookResponses
        "2XX": { description: Received. Anything else (or no answer within 10 seconds) is retried. }
      security: []
  rates.scheduled:
    post:
      summary: A future-dated change was entered for a location you use
      description: |
        Announces the next change ahead for a location (sent as soon as it is
        entered). When its date comes you also get rates.changed.
      parameters: *webhookHeaders
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEvent"
                - type: object
                  properties:
                    type: { const: rates.scheduled }
                    data:
                      type: object
                      properties:
                        count: { type: integer }
                        changes:
                          type: array
                          maxItems: 100
                          items: { $ref: "#/components/schemas/RateScheduled" }
      responses: *webhookResponses
      security: []
  rates.schedule_cancelled:
    post:
      summary: A scheduled change you were told about was cancelled or replaced
      parameters: *webhookHeaders
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEvent"
                - type: object
                  properties:
                    type: { const: rates.schedule_cancelled }
                    data:
                      type: object
                      properties:
                        count: { type: integer }
                        changes:
                          type: array
                          maxItems: 100
                          items: { $ref: "#/components/schemas/RateScheduleCancelled" }
      responses: *webhookResponses
      security: []
  rates.affects_open_documents:
    post:
      summary: A rate change reaches open quotes or sales orders before they're likely to be invoiced
      description: |
        Sent by the daily check (gaps G21) for live documents that are open
        or partially invoiced, once per document version and change: a rate
        of one of its jurisdictions that changes between its tax date and
        when it's likely to be invoiced (`expected_on`, else `expires_on`,
        else any later date), a correction of the rate it was quoted at, a
        tax that ends, a jurisdiction joining or leaving its tax area, or its
        address moving to another tax area. Official data only. Changes found
        when the document was saved were in that answer (`rate_changes`) and
        aren't sent again. Up to 100 documents per event; sandbox documents
        aren't sent (endpoints aren't per environment).
      parameters: *webhookHeaders
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEvent"
                - type: object
                  properties:
                    type: { const: rates.affects_open_documents }
                    data:
                      type: object
                      properties:
                        count: { type: integer, description: "Documents in this event" }
                        documents:
                          type: array
                          maxItems: 100
                          items:
                            type: object
                            properties:
                              document_id: { type: string }
                              type: { type: string, enum: [quote, sales_order] }
                              version: { type: integer }
                              status: { type: string, enum: [open, partially_invoiced] }
                              tax_date: { type: string, format: date }
                              expires_on: { type: [string, "null"], format: date }
                              expected_on: { type: [string, "null"], format: date }
                              changes: { type: array, items: { $ref: "#/components/schemas/DocumentRateChange" } }
      responses: *webhookResponses
      security: []
  webhook.test:
    post:
      summary: Sent from the dashboard's "send test" button
      parameters: *webhookHeaders
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEvent"
                - type: object
                  properties:
                    type: { const: webhook.test }
                    data:
                      type: object
                      properties:
                        message: { type: string }
                        endpoint_id: { type: string }
      responses: *webhookResponses
      security: []
components:
  securitySchemes:
    bearerAuth: { type: http, scheme: bearer }
    platformAuth: { type: http, scheme: bearer, description: "A platform partner's credential (thp_…)" }
  headers:
    RequestId:
      description: Unique id for this request (also in the body as request_id)
      schema: { type: string, example: req_9f3k2m1x0a7b4c5d6e8f }
    RetryAfter:
      description: Seconds to wait before retrying
      schema: { type: integer }
  responses:
    Error:
      description: Uniform error shape
      headers:
        X-Request-Id: { $ref: "#/components/headers/RequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorBody" }
    TransactionWritten:
      description: The transaction as stored (201 written, 200 replayed or revised)
      headers:
        X-Request-Id: { $ref: "#/components/headers/RequestId" }
        Idempotent-Replayed: { description: "true when the answer is the first one for this Idempotency-Key", schema: { type: string } }
      content:
        application/json:
          schema:
            type: object
            properties:
              request_id: { type: string }
              replayed: { type: boolean, description: "the same document was already stored" }
              calculation_id: { type: string, description: "the calculation committed (stored or made now)" }
              rates_changed_since_document:
                type: object
                description: >-
                  With quote_calculation_id, or a stored source_document_id —
                  what changed between the quote or order and this invoice. For
                  a stored document with line references each invoice line is
                  compared with the line it bills, the quoted tax pro rata to
                  what's invoiced (basis lines); otherwise the whole document
                  with the whole invoice (basis document).
                properties:
                  document_id: { type: string, description: "Stored documents only" }
                  document_type: { type: string, enum: [quote, sales_order] }
                  document_version: { type: integer }
                  basis: { type: string, enum: [lines, document] }
                  quote_calculation_id: { type: string }
                  quote_date: { type: [string, "null"], format: date }
                  changed: { type: boolean }
                  quote_tax: { type: number }
                  invoice_tax: { type: number }
                  jurisdictions:
                    type: array
                    items:
                      type: object
                      properties:
                        jurisdiction_id: { type: [string, "null"] }
                        name: { type: [string, "null"] }
                        level: { type: string }
                        quote_rates: { type: array, items: { type: number } }
                        invoice_rates: { type: array, items: { type: number } }
                        quote_tax: { type: number }
                        invoice_tax: { type: number }
              source_document:
                type: object
                description: A stored source_document_id after this invoice
                properties:
                  document_id: { type: string }
                  type: { type: string, enum: [quote, sales_order] }
                  status: { $ref: "#/components/schemas/DocumentStatus" }
                  version: { type: integer }
                  lines:
                    type: array
                    items:
                      type: object
                      properties:
                        line_id: { type: string }
                        quantity: { type: number }
                        invoiced_quantity: { type: number }
              warnings: { type: array, items: { type: string } }
              transaction: { $ref: "#/components/schemas/Transaction" }
    DocumentWritten:
      description: The document as stored (201 created, 200 replayed, revised, cancelled or closed)
      headers:
        X-Request-Id: { $ref: "#/components/headers/RequestId" }
        Idempotent-Replayed: { description: "true when the answer is the first one for this Idempotency-Key", schema: { type: string } }
      content:
        application/json:
          schema:
            type: object
            properties:
              request_id: { type: string }
              replayed: { type: boolean }
              calculation_id: { type: string, description: "Save only: the calculation stored with it" }
              warnings: { type: array, items: { type: string } }
              document: { $ref: "#/components/schemas/Document" }
    RateLimited:
      description: rate_limited (per-key rate limit) or quota_exceeded (monthly spend cap); never billed
      headers:
        X-Request-Id: { $ref: "#/components/headers/RequestId" }
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorBody" }
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Any string up to 255 characters. A retry with the same key gets the first answer (Idempotent-Replayed true) for 24 hours; the same key with another body is refused.
      schema: { type: string, maxLength: 255 }
    TransactionId:
      name: transaction_id
      in: path
      required: true
      description: Your invoice, order or refund number (no slashes)
      schema: { type: string, maxLength: 200 }
    DocumentId:
      name: document_id
      in: path
      required: true
      description: Your quote or sales order number (no slashes)
      schema: { type: string, maxLength: 200 }
    WebhookSignature:
      name: TaxHub-Signature
      in: header
      required: true
      description: |
        `t=<unix seconds>,v1=<hex>`: HMAC-SHA256 of `<t>.<raw request body>`,
        keyed with the endpoint's whole `whsec_…` signing secret. Compare in
        constant time, and reject timestamps more than a few minutes old.
      schema: { type: string, example: "t=1793450000,v1=5d1c…" }
    WebhookEventId:
      name: TaxHub-Event-Id
      in: header
      required: true
      description: The event's id (also `id` in the body); the same on every retry, so use it to ignore duplicates.
      schema: { type: string, example: evt_0f8b6c2e1a2b4c3d8e9f001122334455 }
    WebhookEventType:
      name: TaxHub-Event-Type
      in: header
      required: true
      schema: { type: string }
    WebhookDeliveryId:
      name: TaxHub-Delivery-Id
      in: header
      required: true
      description: This delivery (one event to one endpoint).
      schema: { type: string }
    WebhookDeliveryAttempt:
      name: TaxHub-Delivery-Attempt
      in: header
      required: true
      description: 1 for the first try; retries after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h (8 attempts).
      schema: { type: integer }
  schemas:
    LookupRequest:
      type: object
      required: [zip]
      properties:
        country: { type: string, enum: [US] }
        zip: { type: string }
        state: { type: string }
        city: { type: string }
        street: { type: string }
        category_code: { type: string }
        item_code: { type: string }
        on_date: { type: string, format: date }
        fallback: { type: string, enum: [earliest_known] }
    RateResponse:
      type: object
      properties:
        request_id: { type: string }
        accuracy: { type: string, enum: [address, zip_estimate] }
        location:
          type: object
          properties:
            country: { type: string }
            state: { type: [string, "null"] }
            county: { type: [string, "null"] }
            city: { type: [string, "null"] }
            zip: { type: string }
        combined_rate: { type: number }
        breakdown:
          type: object
          properties:
            state_rate: { type: number }
            county_rate: { type: number }
            city_rate: { type: number }
            special_rate: { type: number }
        freight_taxable: { type: boolean }
        category:
          type: object
          properties:
            code: { type: string }
            name: { type: string }
            treatment: { type: string, enum: [taxable, exempt, reduced, exempt_below_threshold] }
            threshold: { type: number }
            reduced_rate: { type: number }
        source:
          type: string
          enum: [cache, provider, manual, import, override, state_config, none, official]
          description: >-
            `official`: the rate master (state rate and boundary files, state
            agencies), served in states switched to official data. A tenant's
            own override still wins over it.
        stale: { type: boolean }
        assumed: { type: boolean }
        reason: { type: string, description: "e.g. no_nexus, no_sales_tax_state, military_address (APO, FPO or DPO mail: AA, AE, AP)" }
        rate_version: { type: [integer, "null"] }
        effective_from: { type: [string, "null"], format: date }
        on_date: { type: string, format: date, description: "The tax date the answer is for" }
        as_of: { type: [string, "null"], format: date-time, description: "The moment whose knowledge the answer used (null: now)" }
        history_quality:
          type: string
          enum: [official, reconstructed, manual, vendor_observed, imported]
          description: >-
            How good the rate's history is: official (a state file or notice covers the date), reconstructed
            (staff backfilled it from a document), manual (staff entry), vendor_observed (TaxJar), imported (a
            rate import). For an official answer, its weakest jurisdiction's.
        confidence:
          type: string
          enum: [rooftop, place, zip4, zip5, election]
          description: >-
            Official answers only: how precisely the location was placed. `election`
            = the seller elected one rate for the whole state (a remote seller's Texas
            single local use tax rate or Alabama SSUT, recorded on its registration),
            so the address doesn't change the answer; `rooftop`
            = the state placed the street address (California, CDTFA), or the street
            address fell in the state's own address ranges (Vermont, Missouri); `place`
            = the ZIP spans more than one tax area and the street address's county and
            city (geocoded) single out one of them; `zip4` = a ZIP+4 range in the
            state's boundary file (the caller's ZIP+4, or the geocoder's), or (Vermont,
            Missouri) the ZIP+4's address ranges when they all name one tax area;
            `zip5` = the ZIP's record, which is the lowest combined rate in the ZIP
            where it spans more than one tax area (a warning says so; send the street
            address or the ZIP+4).
        tax_area:
          type: object
          description: Official answers only. The set of jurisdictions at the location.
          properties:
            id: { type: string, format: uuid }
            code: { type: string }
            name: { type: [string, "null"] }
            basis: { type: string, enum: [zip4, zip5, address, place, election] }
            assignment_id: { type: [string, "null"], format: uuid, description: "The state boundary file record that placed the location (SST answers)" }
        jurisdictions:
          type: array
          description: >-
            Official answers only: every jurisdiction taxing here (several special
            districts each appear on their own) and the rate version served.
            `level` is how it adds into `breakdown` (`district` → `special_rate`).
          items:
            type: object
            properties:
              id: { type: string, format: uuid, description: "TaxHub jurisdiction id (ZarMoney TaxItem)" }
              name: { type: string }
              kind: { type: string }
              level: { type: string, enum: [state, county, city, district] }
              geoid: { type: [string, "null"] }
              rate: { type: number }
              rate_version_id: { type: string, format: uuid }
              effective_from: { type: [string, "null"], format: date }
              history_quality: { type: string, enum: [official, reconstructed, manual] }
        warnings:
          type: array
          items: { type: string }
    Address:
      type: object
      required: [zip]
      properties:
        country: { type: string, enum: [US] }
        zip: { type: string, pattern: '^\d{5}(-\d{4})?$', description: "A string — leading zeros matter" }
        state: { type: string, description: "2-letter code; required in `from`" }
        city: { type: string }
        street: { type: string, description: "Full address strongly encouraged" }
    CalculationRequest:
      type: object
      required: [to, lines]
      properties:
        to: { $ref: "#/components/schemas/Address" }
        from:
          oneOf:
            - $ref: "#/components/schemas/Address"
            - type: "null"
          description: >-
            Ship-from address (state required). Omit it to use your default
            location; send null for none (destination-based, e.g. a
            drop-shipped order). Not with ship_from_site_id.
        ship_from_site_id: { type: string, format: uuid, description: "Ship from one of your locations (dashboard → Tax profile → Locations)" }
        lines:
          type: array
          minItems: 1
          maxItems: 100
          items:
            type: object
            required: [unit_price]
            properties:
              id: { type: string, description: "Your reference, echoed back" }
              kind:
                type: string
                enum: [product, service, gift_card, tip, donation, fee, deposit]
                default: product
                description: Gift cards, tips, donations, fees and deposits are kept but aren't sales (no tax, no share of shipping). Send shipping and handling as the order's amounts.
              item_code: { type: string, description: "Your SKU, resolved via your product mappings (or your default category)" }
              category_code: { type: string, description: "Product tax code; send item_code or category_code, not both. Food (40030), where taxable, is taxed at the state's food rates (official data)" }
              quantity: { type: number, minimum: 0.0001, default: 1, description: "Up to 4 decimals" }
              unit_price: { type: number, minimum: 0, description: "Dollars, up to 2 decimals" }
              discount: { type: number, minimum: 0, default: 0, description: "Dollars off this line (not more than quantity × unit_price)" }
              discount_funded_by:
                type: string
                enum: [seller, third_party]
                default: seller
                description: A third party's coupon (reimbursed to you) doesn't lower the taxable price.
              source_line_id:
                type: string
                maxLength: 200
                description: >-
                  On an invoice: the line of the quote or sales order it bills
                  (source_document_id). Default: the document's line with the
                  same id. Not taxed on.
              fee_codes:
                type: array
                maxItems: 20
                items: { type: string, pattern: "^[a-z]{2}_[a-z0-9_]{2,80}$" }
                description: >-
                  Retailer-collected charges (roadmap P3.11) on this item, by
                  code: a charge whose amount depends on the item (a screen or
                  container size, a tire type: ca_cew_fee_medium,
                  wa_paint_assessment_large) applies only when named here.
                  Given, exactly these charges apply (an empty list: none, such
                  as tires sold with a vehicle); omitted, every charge on the
                  item's category that doesn't depend on its size. The codes are
                  on the dashboard's Tax profile → Charges.
              hosting:
                type: object
                additionalProperties: false
                description: >-
                  Software on this line (roadmap P3.13): where its servers are
                  and whether the buyer can download it, for states whose rule
                  depends on them (Maine and Mississippi don't tax software
                  hosted outside the state with nothing to download). Without
                  it, such a line is taxed and carries a warning saying what to
                  send.
                properties:
                  server_states:
                    type: array
                    maxItems: 60
                    items: { type: string, pattern: "^[A-Za-z]{2}$" }
                    description: The states the software's servers are in (an empty list, none in any state).
                  downloadable: { type: boolean, description: Whether any of the software can be downloaded onto the buyer's device. }
              holiday_excluded:
                type: boolean
                default: false
                description: >-
                  A sales tax holiday never covers this line: it's something
                  the holiday excludes that TaxHub has no category for (a
                  motor vehicle, alcohol, an accessory a clothing holiday
                  leaves out). An order a holiday with such exclusions applies
                  to carries a warning naming them.
              outside_contract:
                type: boolean
                default: false
                description: >-
                  In a construction contract (contract_type lump_sum_improvement
                  or time_and_materials): this line is taxed as an ordinary
                  sale or service (by its category), not by the contract: an
                  item that stays tangible personal property when installed (a
                  free-standing appliance, equipment, what the state says isn't
                  part of real property, such as carpet in Ohio), or work the
                  state taxes whatever the contract (New Jersey: flooring,
                  landscaping, alarm systems). Mark the item and its
                  installation labor alike. Ignored otherwise.
              single_article:
                type: string
                enum: [unit, line, none]
                default: unit
                description: >-
                  Where a state taxes a single article's price in part
                  (Florida: county surtax on the first $5,000 of an item;
                  Tennessee: local tax on the first $1,600, plus a 2.75% state
                  single article tax on $1,600.01–$3,200), or a city taxes a
                  single item over an amount at a lower rate (Arizona's cities:
                  Phoenix 2% on the part over $14,338, Avondale 1.5% on the
                  whole price over $5,000): unit (each unit is one article),
                  line (the whole line is one article: a set or a bulk sale
                  sold as one item), none (the rule doesn't apply to this
                  line). Goods lines only; services are taxed in full.
              ship_to: { $ref: "#/components/schemas/Address" }
              ship_from_site_id: { type: string, format: uuid, description: "This line ships from one of your locations" }
        shipping:
          oneOf:
            - { type: number, minimum: 0 }
            - type: object
              properties:
                amount: { type: number, minimum: 0 }
                buyer_arranged_third_party_carrier: { type: boolean, description: "The buyer arranged and pays the carrier: delivery isn't taxed" }
          default: 0
        handling: { type: number, minimum: 0, default: 0, description: "Taxable when the goods are" }
        on_date: { type: string, format: date, description: "Calculate with the rates, and the state's sourcing and rounding rules, effective on this date. Default: today in the account's time zone (dashboard settings; from the ship-from state when not set), never the UTC date." }
        as_of: { type: string, format: date-time, description: "What TaxHub knew at this moment (not in the future): official answers use the rates and boundary records as recorded then. Default: now." }
        rate_lock:
          type: object
          required: [basis, contract_date, reference]
          description: >-
            Hold rates at a contract's date where the law allows (roadmap
            P2B.6). ca_fixed_price_contract: California district taxes on a
            fixed-price contract made before a district tax began or rose
            are charged at the lesser of the rate on the tax date and on the
            contract date (R&TC 7261, 7262; Reg. 1823; CDTFA Publication 44).
            The contract must have been binding before that date, for a
            fixed price with no clause allowing changes, and state the tax
            or include it; keep it with your records. The statewide 7.25%
            never locks; a decrease still applies. Needs TaxHub's official
            California answer (street, city and ZIP). Send it on the invoice
            too (and the quote or order) to hold the invoice's district taxes.
          properties:
            basis: { type: string, enum: [ca_fixed_price_contract] }
            contract_date: { type: string, format: date, description: "When the contract became binding (not after the tax date)" }
            reference: { type: string, maxLength: 200, description: "The contract's number or name, kept as the lock's evidence" }
        fallback: { type: string, enum: [earliest_known] }
        customer_id: { type: string, maxLength: 200, description: "The customer's id in your system: its exemption grants (dashboard → Tax profile → Customers) apply per state" }
        customer_channel: { type: string, default: zarmoney, description: "The system customer_id comes from" }
        exemption_type:
          type: string
          enum: [non_exempt, wholesale, government, marketplace, other]
          default: non_exempt
          description: The older switch (not with customer_id) — an exempt customer pays no tax on any line or charge. TaxHub doesn't validate exemption certificates.
        buyer_use:
          type: string
          enum: [business, personal]
          description: >-
            What the buyer buys for (roadmap P3.13), where a state's rule
            depends on it: Connecticut taxes software bought by a business for
            business use at 1% (6.35% for personal use); Maryland taxes
            software for use in a business's enterprise computer system at 3%
            from July 2025 (exempt before), 6% for personal use. Without it,
            such a line is taxed as for personal use and carries a warning.
        delivery:
          type: string
          enum: [delivered, pickup]
          default: delivered
          description: >-
            A retail delivery fee (Colorado, Minnesota; roadmap P3.11) is
            charged on deliveries only: send pickup for an order the buyer
            collects.
        contract_type:
          type: string
          enum: [retail_sale, lump_sum_improvement, time_and_materials]
          default: retail_sale
          description: >-
            What the order is (roadmap P3.9), for any trade. retail_sale:
            goods sold, with any installation of goods that stay goods (an
            appliance, a machine) as a service line (category 10040).
            lump_sum_improvement: one price for work on real property that
            furnishes materials becoming part of it (roofing, HVAC, plumbing,
            electrical, flooring, cabinets, windows: building, remodeling or
            repairing). time_and_materials: the same work with materials and
            labor stated separately (send the labor as kind service or a
            service category such as 10040). TaxHub taxes it as the state's
            contractor rule says, citing it on each line: where the
            contractor is the consumer of its materials, the customer is
            charged no tax (exemption_reason contractor_consumer); where the
            contract itself is taxed, every line at the full rate, labor
            included; separated, the materials and the labor each as the
            state says. A state with no rule in TaxHub's library, one that
            depends on more than the order says, or one that depends on
            work_type or property_type when the order doesn't send them, is
            taxed as a retail sale, with a warning. A construction contract is
            taxed at the delivery address (the jobsite), never at the
            ship-from address, even in an origin-based state; its
            outside_contract lines are ordinary sales, sourced as usual (a
            tax area of their own). Where a state's rule depends on whether
            you're a contractor or a retailer-contractor (CA, CO, ME, MI, NE,
            SC, VT), TaxHub uses your registration's setting (dashboard → Tax
            profile → Registrations → the state → Construction contracts);
            unset, the order is a retail sale with a warning (Nebraska: its
            default, Option 1).
        work_type:
          type: string
          enum: [new_construction, remodel, repair]
          description: >-
            With a construction contract_type, where a state's rule depends
            on it: new_construction (a new structure or an addition), remodel
            (remodeling, renovation or another lasting improvement of
            existing property; New York's capital improvement), repair
            (repair, maintenance or restoration). Refused without a
            construction contract_type.
        property_type:
          type: string
          enum: [residential, nonresidential]
          description: >-
            With a construction contract_type, where a state's rule depends
            on it (Texas taxes labor to repair or remodel nonresidential
            property, not residential). Refused without a construction
            contract_type.
    CalculationResponse:
      type: object
      properties:
        request_id: { type: string }
        calculation_id: { type: string, format: uuid, description: "Commit it with POST /v1/transactions" }
        on_date: { type: string, format: date }
        as_of: { type: string, format: date-time, description: "Echoed when the request sent it" }
        rate_lock:
          type: object
          description: What the request's rate_lock did
          properties:
            basis: { type: string }
            contract_date: { type: string, format: date }
            reference: { type: string }
            status: { type: string, enum: [applied, no_change, not_applied], description: "applied: district taxes held; no_change: none rose since the contract; not_applied: see reason (also a warning)" }
            reason: { type: string }
            jurisdictions:
              type: array
              items:
                type: object
                properties:
                  jurisdiction_id: { type: string, format: uuid }
                  name: { type: string }
                  rate_on_tax_date: { type: number }
                  locked_rate: { type: number, description: "Charged (0: a district tax the contract predates)" }
                  rate_version_id: { type: [string, "null"], description: "The version charged (the contract date's); the ledger cell cites it" }
        currency: { type: string, enum: [USD] }
        sourcing:
          type: object
          properties:
            method: { type: string, enum: [destination, origin, modified_origin] }
            reason: { type: string }
        taxed_location:
          type: object
          description: Where the rate comes from (the ship-from address for origin sourcing)
          properties:
            country: { type: string }
            state: { type: [string, "null"] }
            county: { type: [string, "null"] }
            city: { type: [string, "null"] }
            zip: { type: string }
        rate:
          type: object
          description: The location's rate before category rules
          properties:
            combined_rate: { type: number }
            breakdown:
              type: object
              properties:
                state_rate: { type: number }
                county_rate: { type: number }
                city_rate: { type: number }
                special_rate: { type: number }
            freight_taxable: { type: boolean }
            source: { type: string, enum: [cache, provider, manual, import, override, state_config, none, official] }
            accuracy: { type: string, enum: [address, zip_estimate] }
            rate_version: { type: [integer, "null"] }
            effective_from: { type: [string, "null"], format: date }
            stale: { type: boolean }
            assumed: { type: boolean }
            confidence: { type: string, enum: [rooftop, place, zip4, zip5, election], description: "Official answers only (see RateResponse)" }
            tax_area:
              type: object
              description: Official answers only.
              properties:
                id: { type: string, format: uuid }
                code: { type: string }
                name: { type: [string, "null"] }
                basis: { type: string, enum: [zip4, zip5, address, place, election] }
        exemption_type: { type: string }
        contract_type: { type: string, description: "Echoed when the request sent one other than retail_sale" }
        work_type: { type: string, description: "Echoed when sent" }
        property_type: { type: string, description: "Echoed when sent" }
        customer:
          type: object
          properties:
            id: { type: string }
            channel: { type: string }
            on_file: { type: boolean, description: "false: no customer with this id; taxed without exemptions (with a warning)" }
        reason: { type: string, description: "Set when nothing is taxed: no_nexus, no_sales_tax_state, military_address" }
        rounding:
          type: object
          description: Your rounding policy, and any state rule applied on top
          properties:
            preset: { type: string, enum: [match_shopify, lowest_total, legacy, custom] }
            basis: { type: string, enum: [line, document] }
            granularity: { type: string, enum: [per_jurisdiction, combined_rate] }
            mode: { type: string, enum: [half_up, half_even, up, down] }
            state_rules:
              type: array
              items:
                type: object
                properties:
                  state: { type: string }
                  basis: { type: string }
                  granularity: { type: string }
                  mode: { type: string }
                  notes: { type: string }
        lines:
          type: array
          items:
            type: object
            properties:
              line: { type: integer }
              id: { type: string }
              item_code: { type: string }
              category_code: { type: string }
              category:
                type: object
                properties:
                  code: { type: string }
                  name: { type: string }
                  treatment: { type: string, enum: [taxable, exempt, reduced, exempt_below_threshold] }
                  threshold: { type: number }
                  reduced_rate: { type: number }
              quantity: { type: number }
              unit_price: { type: number }
              discount: { type: number }
              amount: { type: number, description: "quantity × unit_price, rounded to the cent" }
              taxable_amount: { type: number, description: "The part of the line's price taxed. Where a state taxes only part of an item's price (Massachusetts clothing over $175, Texas SaaS at 80%), less than the price, the rest in exempt_amount with exemption_reason non_taxable_item" }
              exempt_amount: { type: number }
              rate: { type: number, description: "Rate applied to this line after category rules (on taxable_amount); under a single-article rule or a city's single-item tier, the effective rate over the line (each cell has its own taxable amount)" }
              tax: { type: number }
              single_article:
                type: object
                description: >-
                  The state's single-article rule changed this line's tax: its
                  local levels are taxed on at most the cap per article, and
                  (Tennessee) the band's state tax on each article's price
                  between its bounds, booked to its own jurisdiction. A
                  category rule's own cap per item does the same for the
                  levels it names (Arkansas's city and county tax on the
                  first $2,500 of an aircraft or a boat; South Carolina's 5%
                  on the first $10,000, its $500 maximum tax): rule names it,
                  and levels says which levels it caps. Under such a rule the
                  item's share of shipping and handling counts toward the cap
                  (the charge's cells show what's left of it).
                properties:
                  rule: { type: string }
                  articles: { type: number, description: "Articles the cap is counted for: the quantity, or 1 for single_article: line" }
                  levels: { type: array, items: { type: string, enum: [state, county, city, district] }, description: "The levels taxed on at most the cap per article" }
                  local_cap_per_article: { type: number, description: "The cap per article, for the levels named (the state's own level in South Carolina)" }
                  local_taxable_amount: { type: number, description: "Those levels' taxable amount on the line" }
                  band:
                    type: object
                    properties:
                      jurisdiction:
                        type: object
                        properties:
                          id: { type: [string, "null"] }
                          name: { type: [string, "null"] }
                          geoid: { type: [string, "null"] }
                      rate: { type: number }
                      taxable_amount: { type: number }
              city_tier:
                type: object
                description: >-
                  The city's single-item tier changed this line's tax
                  (Arizona's cities, from the Department of Revenue's rate
                  table): kind portion, the part of each article's price over
                  the threshold is taxed at the tier's rate and the rest at
                  the city's; kind whole, an article priced over the
                  threshold is taxed at the tier's rate on its whole price.
                  Both parts are cells of the city (the tier's has no
                  rate_version_id); the state's and county's taxes are on the
                  whole price. series interstate: a remote seller's or
                  marketplace's tier, where the city's remote rate applies.
                properties:
                  rule: { type: string }
                  kind: { type: string, enum: [portion, whole] }
                  series: { type: string, enum: [interstate] }
                  jurisdiction:
                    type: object
                    properties:
                      id: { type: [string, "null"] }
                      name: { type: [string, "null"] }
                      geoid: { type: [string, "null"] }
                  articles: { type: number, description: "Articles the threshold is counted for: the quantity, or 1 for single_article: line" }
                  threshold_per_article: { type: number }
                  rate: { type: number, description: "The tier's rate" }
                  city_rate: { type: number, description: "The city's own rate" }
                  taxable_at_city_rate: { type: number }
                  taxable_at_tier_rate: { type: number }
              holiday:
                type: object
                description: >-
                  A sales tax holiday covered this goods line on the tax date
                  (its category, at or under the cap per item). levels all:
                  the line is exempt (exemption_reason sales_tax_holiday);
                  levels state: only the state's tax isn't charged, local
                  taxes apply. Services, exempt customers and construction
                  contracts are decided first and never covered.
                properties:
                  id: { type: string, format: uuid }
                  name: { type: string }
                  state: { type: string }
                  levels: { type: string, enum: [all, state] }
              kind: { type: string, description: "When not product" }
              discount_funded_by: { type: string, enum: [third_party] }
              tax_area: { type: integer, description: "Which of tax_areas the line is in (orders with more than one)" }
              exemption_reason:
                type: string
                enum: [resale, government_federal, government_state_local, tribal, nonprofit, religious_educational, manufacturing, agricultural, direct_pay, diplomat, marketplace_collected, other, no_nexus, no_sales_tax, non_taxable_item, non_taxable_shipping, contractor_consumer, sales_tax_holiday]
              cells: { type: array, items: { $ref: "#/components/schemas/TaxCell" } }
              note: { type: string, description: "Why the line is exempt, reduced, or taxable at a threshold" }
              warnings: { type: array, items: { type: string } }
        shipping: { $ref: "#/components/schemas/Charge" }
        handling: { $ref: "#/components/schemas/Charge" }
        jurisdictions:
          type: array
          description: Tax per jurisdiction (Σ of its cells)
          items:
            type: object
            properties:
              id: { type: [string, "null"], description: "null: the rate's location isn't linked to a jurisdiction yet" }
              level: { type: string, enum: [state, county, city, district] }
              name: { type: [string, "null"] }
              geoid: { type: [string, "null"] }
              taxable_amount: { type: number }
              tax: { type: number }
        breakdown:
          type: object
          description: Tax per level (Σ of the cells); always adds up to totals.tax
          properties:
            state_tax: { type: number }
            county_tax: { type: number }
            city_tax: { type: number }
            special_tax: { type: number }
        totals:
          type: object
          properties:
            subtotal: { type: number }
            discount: { type: number }
            shipping: { type: number }
            handling: { type: number }
            non_sale: { type: number, description: "Gift cards, tips and other non-sale lines" }
            taxable_amount: { type: number }
            exempt_amount: { type: number }
            tax: { type: number }
            fees: { type: number, description: "Retailer-collected charges the buyer pays (when there are any)" }
            total: { type: number, description: "subtotal − discount + shipping + handling + non_sale + tax + fees" }
        fees:
          type: array
          description: >-
            Retailer-collected charges besides sales tax (roadmap P3.11): a
            fee per delivery (Colorado's and Minnesota's retail delivery fees:
            once per delivery with taxable goods, Minnesota's on $100 or more of
            them with their delivery), per item (tire, mattress, battery, paint
            and electronic waste fees) or a percent of the price (California's
            lumber assessment), as the state's law sets them on the tax date,
            only where the account has nexus and never on a sale for resale.
            The account chooses per charge on Tax profile → Charges: collect
            it (the default), exempt as a small seller, or pay it itself
            (collected false: not in the total). A charge subject to sales
            tax (Florida's and Kentucky's tire fees) is taxed with its item:
            its tax is in that line's cells.
          items:
            type: object
            properties:
              code: { type: string }
              name: { type: string }
              label: { type: string, description: "The words the receipt shows" }
              state: { type: string }
              basis: { type: string, enum: [per_delivery, per_unit, percent_of_price] }
              line: { type: [integer, "null"], description: "The line it's on; null: per delivery" }
              quantity: { type: number, description: "Items (the line's quantity) or deliveries (1): what the charge's own return counts" }
              base: { type: number, description: "A percent of the price: the price it's a percent of (after seller discounts, with the line's share of handling)" }
              amount: { type: number }
              collected: { type: boolean, description: "false: the seller pays it" }
              taxable: { type: boolean, description: "Sales tax applies to it (in its line's tax)" }
        tax_areas:
          type: array
          description: Each group of lines with the same ship-to and ship-from, sourced and looked up on its own
          items:
            type: object
            properties:
              area: { type: integer }
              lines: { type: array, items: { type: integer } }
              ship_to: { $ref: "#/components/schemas/Address" }
              ship_from: { oneOf: [{ $ref: "#/components/schemas/Address" }, { type: "null" }] }
              ship_from_site: { type: object, properties: { id: { type: string }, site_code: { type: string } } }
              sourcing: { type: object, properties: { method: { type: string }, reason: { type: string } } }
              taxed_location: { type: object }
              rate: { type: object }
              shipping: { type: object, properties: { taxable: { type: boolean }, rule: { type: string } } }
              rounding: { type: object }
              exemption: { type: object, properties: { reason: { type: string }, grant_id: { type: string }, certificate_id: { type: [string, "null"] } } }
        warnings: { type: array, items: { type: string } }
    Jurisdiction:
      type: object
      properties:
        id: { type: string, format: uuid }
        state: { type: string }
        kind: { type: string, enum: [state, county, city, special_district, district_composite, transit, tribal, other] }
        level: { type: string, enum: [state, county, city, district], description: The level a ledger cell books it at }
        name: { type: string }
        full_name: { type: string }
        lsad: { type: [string, "null"], description: "Census legal description (County, city, town, …)" }
        parent_id: { type: [string, "null"] }
        geoid: { type: [string, "null"], description: "Census GEOID: 2 digits (state), 5 (county), 7 (place)" }
        fips:
          type: object
          properties:
            state: { type: [string, "null"] }
            county: { type: [string, "null"] }
            place: { type: [string, "null"] }
        status: { type: string, enum: [active, inactive] }
        verification: { type: string, enum: [official, unverified] }
        effective_from: { type: [string, "null"], format: date }
        effective_to: { type: [string, "null"], format: date }
        superseded_by_id: { type: [string, "null"], description: The jurisdiction that replaced it }
        codes:
          type: array
          description: "Agency and vendor codes (ca_district_code, tx_local_7, wa_location_4, shopify_title, …)"
          items:
            type: object
            properties:
              scheme: { type: string }
              code: { type: string }
              valid_from: { type: [string, "null"], format: date }
              valid_to: { type: [string, "null"], format: date }
    PlatformTenant:
      type: object
      properties:
        id: { type: string, format: uuid }
        account_id: { type: string }
        name: { type: string }
        slug: { type: string }
        status: { type: string, enum: [active, suspended] }
        created_at: { type: string, format: date-time }
        dashboard_url: { type: string }
        sso_url: { type: string, description: Where the partner sends people with a sign-in token }
    IssuedKey:
      type: object
      properties:
        key: { type: string, description: The API key (thk_live_… / thk_test_…), shown once }
        prefix: { type: string }
        scopes: { type: array, items: { type: string } }
    TransactionCommitFields:
      type: object
      required: [document_date]
      properties:
        document_date: { type: string, format: date, description: "The invoice date" }
        tax_date: { type: string, format: date, description: "The date the tax is due on (default document_date): rates, nexus and rules of this date" }
        channel: { type: string, description: "Where the order came from (zarmoney, shopify, …)" }
        external_order_id: { type: string, description: "Its id there (needs channel); one sale per channel order" }
        source_document_id:
          type: string
          description: >-
            The quote or sales order this invoices. When it's stored
            (POST /v1/documents), the answer compares this invoice with it
            line by line (rates_changed_since_document), counts it against the
            document (source_document) and warns if the document ended; any
            other value is kept as a reference, with a warning.
        quote_calculation_id: { type: string, format: uuid, description: "The quote's calculation: the answer's rates_changed_since_document compares with it (wins over a stored document's)" }
    TransactionFromCalculation:
      description: Commit a calculation made earlier (no order fields)
      allOf:
        - $ref: "#/components/schemas/TransactionCommitFields"
        - type: object
          required: [calculation_id]
          properties:
            calculation_id: { type: string, format: uuid, description: "From POST /v1/calculations, made for the same tax date" }
    TransactionFromOrder:
      description: Send the order (every CalculationRequest field except on_date); TaxHub calculates it on the tax date
      allOf:
        - $ref: "#/components/schemas/TransactionCommitFields"
        - $ref: "#/components/schemas/CalculationRequest"
    TransactionCommit:
      description: POST /v1/transactions
      allOf:
        - type: object
          required: [transaction_id]
          properties:
            transaction_id: { type: string, maxLength: 200, description: "Your invoice number, unique per environment" }
        - oneOf:
            - $ref: "#/components/schemas/TransactionFromCalculation"
            - $ref: "#/components/schemas/TransactionFromOrder"
    TransactionRevision:
      description: PUT /v1/transactions/{transaction_id} (transaction_id comes from the URL; if sent, it must match)
      oneOf:
        - $ref: "#/components/schemas/TransactionFromCalculation"
        - $ref: "#/components/schemas/TransactionFromOrder"
    DocumentStatus:
      type: string
      enum: [open, partially_invoiced, invoiced, expired, converted, cancelled, closed, superseded]
      description: >-
        From the ledger: invoices are committed sales naming the document;
        invoiced when every sold line's quantity is invoiced (or an invoice
        names the document but none of its lines); expired when a quote is
        past expires_on with nothing invoiced; superseded for an old version.
    DocumentRequest:
      description: POST /v1/documents and PUT /v1/documents/{document_id}
      allOf:
        - type: object
          required: [document_date]
          properties:
            document_id: { type: string, maxLength: 200, description: "Your quote or order number, unique per environment (POST; on PUT it comes from the URL)" }
            type: { type: string, enum: [quote, sales_order], description: "Required on POST" }
            document_date: { type: string, format: date }
            tax_date: { type: string, format: date, description: "The date the estimate is for (default document_date)" }
            expires_on: { type: [string, "null"], format: date, description: "A quote's last valid day (default: 30 days after document_date)" }
            expected_on: { type: [string, "null"], format: date, description: "When it's expected to be invoiced (shipped)" }
            converted_from: { type: string, description: "A sales order: the quote it was made from (on creation)" }
            channel: { type: string }
            external_order_id: { type: string, description: "Needs channel" }
        - oneOf:
            - type: object
              required: [calculation_id]
              properties:
                calculation_id: { type: string, format: uuid, description: "From POST /v1/calculations, for the same tax date" }
            - $ref: "#/components/schemas/CalculationRequest"
    DocumentRateChange:
      type: object
      description: A change known to reach a document before it's likely to be invoiced
      properties:
        kind: { type: string, enum: [rate_change, corrected, tax_ends, new_tax, area_change] }
        starts_on: { type: string, format: date }
        jurisdiction_id: { type: [string, "null"], format: uuid }
        name: { type: [string, "null"] }
        level: { type: [string, "null"] }
        from_rate: { type: [number, "null"] }
        to_rate: { type: [number, "null"] }
        estimated_difference: { type: [number, "null"], description: "Its estimated effect on the document's tax (dollars), on the taxable amount quoted at the cited rate" }
    Document:
      type: object
      properties:
        document_id: { type: string }
        type: { type: string, enum: [quote, sales_order] }
        status: { $ref: "#/components/schemas/DocumentStatus" }
        version: { type: integer }
        current_version: { type: integer }
        estimated: { type: boolean, const: true, description: "Label its tax as estimated" }
        document_date: { type: string, format: date }
        tax_date: { type: string, format: date }
        expires_on: { type: [string, "null"], format: date }
        expected_on: { type: [string, "null"], format: date }
        calculation_id: { type: string }
        converted_from: { type: string }
        converted_to: { type: string }
        totals: { type: object, properties: { amount: { type: number }, discount: { type: number }, taxable_amount: { type: number }, tax: { type: number } } }
        lines:
          type: array
          items:
            type: object
            properties:
              line_id: { type: string }
              kind: { type: string }
              quantity: { type: number }
              invoiced_quantity: { type: number }
              amount: { type: number }
              taxable_amount: { type: number }
              tax: { type: number }
              cells: { type: array, items: { type: object } }
        invoices:
          type: array
          items:
            type: object
            properties:
              transaction_id: { type: string }
              version: { type: integer }
              document_date: { type: string, format: date }
              tax_date: { type: string, format: date }
              tax_collected: { type: number }
        rate_changes: { type: array, items: { $ref: "#/components/schemas/DocumentRateChange" } }
        versions: { type: array, items: { type: object } }
        rate_lock: { type: object, description: "The rate lock its calculation applied (CalculationResponse.rate_lock); district increases don't reach a locked document" }
    ImportTaxLine:
      type: object
      required: [title, rate_percentage, amount]
      properties:
        title: { type: string, description: "As the platform showed it (Clay County Tax)" }
        jurisdiction_id: { type: string, format: uuid, description: "TaxHub's jurisdiction, when the sender knows it (a calculation cell's jurisdiction.id; ZarMoney's TaxItem). Maps the line before the title does." }
        rate_percentage: { type: number, description: "Percent, up to 4 decimals (6.875); never the decimal rate" }
        amount: { type: number, description: "Dollars the platform charged for this jurisdiction on this line" }
        channel_liable: { type: boolean, description: "A marketplace collected and remits it" }
    ImportRequest:
      type: object
      description: A generic order with tax lines, or format shopify with the Admin GraphQL order (transaction_id defaults to its name)
      properties:
        format: { type: string, enum: [lines, shopify], default: lines }
        order: { type: object, description: "format shopify: data.order from Admin GraphQL" }
        transaction_id: { type: string }
        channel: { type: string }
        source: { type: string, enum: [shopify, taxjar, avalara, stripe_tax, manual, zarmoney_fallback] }
        external_order_id: { type: string }
        document_date: { type: string, format: date, description: "Shopify: defaults to processedAt in your time zone" }
        tax_date: { type: string, format: date }
        ship_to: { $ref: "#/components/schemas/Address" }
        ship_from: { oneOf: [{ $ref: "#/components/schemas/Address" }, { type: "null" }] }
        ship_from_site_id: { type: string, format: uuid }
        customer_id: { type: string }
        customer_channel: { type: string }
        lines:
          type: array
          items:
            type: object
            properties:
              line_id: { type: string }
              kind: { type: string, enum: [product, service, gift_card, tip, donation, fee, deposit] }
              item_code: { type: string }
              description: { type: string }
              quantity: { type: number }
              unit_price: { type: number }
              amount: { type: number, description: "Instead of quantity × unit_price" }
              discount: { type: number }
              discount_funded_by: { type: string, enum: [seller, third_party] }
              taxable: { type: boolean, description: "The platform's flag" }
              ship_to: { $ref: "#/components/schemas/Address" }
              tax_lines: { type: array, items: { $ref: "#/components/schemas/ImportTaxLine" } }
        shipping:
          description: One charge or a list
          oneOf:
            - { type: object, properties: { amount: { type: number }, tax_lines: { type: array, items: { $ref: "#/components/schemas/ImportTaxLine" } } } }
            - { type: array, items: { type: object, properties: { amount: { type: number }, tax_lines: { type: array, items: { $ref: "#/components/schemas/ImportTaxLine" } } } } }
        handling: { type: object, properties: { amount: { type: number }, tax_lines: { type: array, items: { $ref: "#/components/schemas/ImportTaxLine" } } } }
        total_tax: { type: number, description: "The platform's order tax total: checked against the lines" }
        order_tax_lines: { type: array, items: { type: object, properties: { title: { type: string }, amount: { type: number } } }, description: "Per-title totals: checked too" }
    RefundRequest:
      type: object
      required: [refund_id, document_date]
      properties:
        refund_id: { type: string, maxLength: 200, description: "Your credit memo or refund number: the refund's transaction_id" }
        document_date: { type: string, format: date }
        lines:
          type: array
          items:
            type: object
            required: [line_id]
            properties:
              line_id: { type: string, description: "The sale's line id (your id, or L1, L2 … by position)" }
              amount: { type: number, description: "Dollars of the line's price before tax; omit for the rest of the line" }
        shipping: { oneOf: [{ type: number }, { type: boolean }], description: "Dollars of the shipping, or true for the rest of it" }
        handling: { oneOf: [{ type: number }, { type: boolean }] }
    Transaction:
      type: object
      properties:
        transaction_id: { type: string }
        version: { type: integer }
        type: { type: string, enum: [sale, refund, tax_only_credit] }
        status: { type: string, enum: [committed, superseded, voided] }
        source: { type: string }
        channel: { type: string }
        external_order_id: { type: string }
        original_transaction_id: { type: string, description: "Refunds: the sale" }
        document_date: { type: string, format: date }
        tax_date: { type: string, format: date }
        posted_at: { type: string, format: date-time }
        customer_ref: { type: [string, "null"], description: "TaxHub's id of the customer, when on file" }
        calculation_id: { type: [string, "null"] }
        collected_by: { type: string, enum: [merchant, marketplace, none] }
        rounding: { type: [object, "null"], description: "The rounding policy the tax was computed under" }
        totals:
          type: object
          properties:
            gross: { type: number }
            discount: { type: number }
            shipping: { type: number }
            handling: { type: number }
            non_sale: { type: number }
            taxable_amount: { type: number }
            exempt_amount: { type: number }
            tax_calculated: { type: number }
            tax_collected: { type: number }
        variance_class: { type: string, enum: [none, penny, method, under, over_material] }
        lines:
          type: array
          items:
            type: object
            properties:
              line_number: { type: integer }
              line_id: { type: string }
              kind: { type: string }
              amount: { type: number }
              discount: { type: number }
              taxable_amount: { type: number }
              exempt_amount: { type: number }
              exemption_reason: { type: string }
              tax_calculated: { type: number }
              tax_collected: { type: number }
              cells:
                type: array
                items:
                  type: object
                  properties:
                    jurisdiction_id: { type: string }
                    level: { type: string }
                    state: { type: string }
                    name: { type: string }
                    codes: { type: object, description: "GEOID, agency and vendor codes on the tax date" }
                    rate: { type: number }
                    rate_version_id: { type: [string, "null"] }
                    taxable_amount: { type: number }
                    tax_calculated: { type: number }
                    tax_collected: { type: number }
                    mapping: { type: string, enum: [engine, jurisdiction_id, exact_map, engine_candidate, quarantined], description: "How the jurisdiction was found: TaxHub's own cell (engine); for imports, named by the sender (jurisdiction_id), your tax line map (exact_map), TaxHub's matching cell (engine_candidate), or none: quarantined, booked at state level until the jurisdiction is linked" }
                    external_label: { type: string }
        charges:
          type: array
          description: >-
            Retailer-collected charges on the transaction (roadmap P3.11),
            when there are any: each one the buyer paid (on its fee line, for
            the same amount) and each one the seller absorbed (not billed, so
            not in the totals, but owed). Recorded with the transaction from
            its calculation, with the amount in effect on the tax date, and
            reported by code on Reports. A refund carries a charge only when
            it refunds the charge's fee line; an absorbed charge stays as
            booked. Never edited: a new version or a refund changes them.
          items:
            type: object
            properties:
              code: { type: string }
              state: { type: string }
              basis: { type: string, enum: [per_delivery, per_unit, percent_of_price] }
              line_id: { type: [string, "null"], description: "The goods line it's on; null: per delivery" }
              paid_by: { type: string, enum: [buyer, seller], description: "seller: absorbed (no fee line)" }
              fee_line_id: { type: string, description: "The buyer's: its fee line" }
              units: { type: number, description: "Items or deliveries (negative on a refund)" }
              base: { type: number, description: "A percent of the price: the price it's a percent of" }
              per_unit: { type: number, description: "The amount in effect per item or delivery" }
              rate: { type: number, description: "The rate in effect (0.01 = 1%)" }
              max_per_unit: { type: number, description: "Its cap per item" }
              amount: { type: number }
    TaxCell:
      type: object
      description: One line or charge × one jurisdiction
      properties:
        level: { type: string, enum: [state, county, city, district] }
        jurisdiction:
          type: object
          properties:
            id: { type: [string, "null"] }
            name: { type: [string, "null"] }
            geoid: { type: [string, "null"] }
        state: { type: [string, "null"] }
        rate: { type: number }
        rate_version_id: { type: [string, "null"], description: "The rate row TaxHub served" }
        taxable_amount: { type: number }
        tax: { type: number }
        tax_exact: { type: string, description: "The tax before rounding, in dollars to 8 decimals" }
    Charge:
      type: object
      properties:
        amount: { type: number }
        taxable_amount: { type: number }
        rate: { type: number, description: "Effective rate over the whole charge" }
        tax: { type: number }
        rule: { type: string, description: "Why the charge is or isn't taxed (per state when the order has several)" }
        cells: { type: array, items: { $ref: "#/components/schemas/TaxCell" } }
        buyer_arranged_third_party_carrier: { type: boolean }
    ErrorBody:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
                - unauthorized
                - key_revoked
                - insufficient_scope
                - ip_not_allowed
                - invalid_zip
                - validation_error
                - quota_exceeded
                - rate_limited
                - provider_unavailable
                - no_rate_for_date
                - not_found
                - internal_error
            message: { type: string }
            request_id: { type: string }
            earliest_available: { type: [string, "null"], format: date }
    WebhookEvent:
      type: object
      required: [id, type, created_at, data]
      properties:
        id: { type: string, description: "evt_ + 32 hex; the same on every retry", example: evt_0f8b6c2e1a2b4c3d8e9f001122334455 }
        type: { type: string, enum: [rates.changed, rates.scheduled, rates.schedule_cancelled, rates.affects_open_documents, webhook.test] }
        created_at: { type: string, format: date-time }
        data: { type: object }
    WebhookLocation:
      type: object
      properties:
        country: { type: string, example: US }
        state: { type: string, example: NY }
        county: { type: [string, "null"], example: PUTNAM }
        city: { type: [string, "null"], example: MAHOPAC }
        zips:
          type: array
          maxItems: 20
          description: Up to 20 of the ZIPs TaxHub maps to this location (for a tax area, the ZIPs its state's boundary file assigns to it)
          items: { type: string }
        zip_count: { type: integer }
        tax_area:
          type: object
          description: >-
            Official answers only: the tax area (the set of jurisdictions at a
            location) whose rate changed. county and city are then null; the
            rates name every jurisdiction.
          properties:
            id: { type: string, format: uuid }
            scheme: { type: string, example: sst_area }
            code: { type: string }
            name: { type: [string, "null"] }
    WebhookRate:
      type: object
      description: A rate as GET /v1/rates reports it.
      properties:
        combined_rate: { type: number, example: 0.08375 }
        breakdown:
          type: object
          properties:
            state_rate: { type: number }
            county_rate: { type: number }
            city_rate: { type: number }
            special_rate: { type: number }
        freight_taxable: { type: boolean }
        source: { type: string, enum: [cache, manual, import, override, official] }
        rate_version: { type: [integer, "null"], description: "null for official answers (each jurisdiction cites its own version)" }
        effective_from: { type: string, format: date, description: "When this version starts" }
        jurisdictions:
          type: array
          description: Official answers only. Every jurisdiction above 0% and the rate version served.
          items:
            type: object
            properties:
              id: { type: string, format: uuid }
              name: { type: string }
              kind: { type: string }
              level: { type: string, enum: [state, county, city, district] }
              rate: { type: number }
              rate_version_id: { type: string, format: uuid }
    RateChanged:
      type: object
      properties:
        effective_from: { type: string, format: date, description: "The day the served rate changed to current" }
        location: { $ref: "#/components/schemas/WebhookLocation" }
        interstate: { type: boolean, description: "Official answers only: the state's rates for sales from another state" }
        previous: { $ref: "#/components/schemas/WebhookRate" }
        current: { $ref: "#/components/schemas/WebhookRate" }
    RateScheduled:
      type: object
      properties:
        effective_from: { type: string, format: date, description: "The day the scheduled rate takes over" }
        location: { $ref: "#/components/schemas/WebhookLocation" }
        interstate: { type: boolean }
        current: { $ref: "#/components/schemas/WebhookRate" }
        scheduled: { $ref: "#/components/schemas/WebhookRate" }
    RateScheduleCancelled:
      type: object
      properties:
        effective_from: { type: string, format: date, description: "The day the cancelled change would have taken effect" }
        location: { $ref: "#/components/schemas/WebhookLocation" }
        interstate: { type: boolean }
        current: { $ref: "#/components/schemas/WebhookRate" }
        cancelled: { $ref: "#/components/schemas/WebhookRate" }
