openapi: 3.1.0
info:
  title: US Tariff and Customs Intelligence API
  version: "1.0.0"
  summary: The complete US Harmonized Tariff Schedule, per-shipment duty estimation, CBP ports of entry, and an archive of HTS revisions.
  description: |
    What it costs to bring anything into the United States, as clean JSON.

    Every duty rate in all 98 chapters of the Harmonized Tariff Schedule — 29,860 lines, of
    which 19,949 are the 10-digit codes an entry is actually filed against — is parsed into
    computable terms and served alongside the string USITC published, so the arithmetic can be
    checked rather than trusted.

    **The estimator computes or it refuses. There is no third answer.** `POST /v1/duty-estimate`
    returns a number with the arithmetic that produced it, or `dutyUsd: null` with the reason.
    A rate defined by reference to another article, a sliding scale keyed to a polarimeter
    reading, a rate stated in a schedule note, a conditional free entry, and a duty apportioned
    across components priced separately are all REPORTED and never priced. `dutyUsd: 0` only
    ever means Free — a zero on a dutiable line looks like good news, which makes it worse than
    an error.

    **Specific rates are why this is worth computing.** A duty of cents per litre or per
    kilogram does not move with customs value, so the effective ad valorem rate moves inversely
    with unit value: the same 1,000 litre wine shipment pays the same duty at a customs value
    of $500 or $40,000 — 12.6% or 0.16%. Send `quantity` and `quantityUnit` in the line's own
    unit and the estimate shows both.

    **Classification is yours.** This service will tell you what a line costs; it will not tell
    you which line your goods are. A prefix matching several lines comes back 409 with the
    candidates rather than picking one.

    **What this API does not have.** Import and export volumes and values, and therefore the
    duty actually collected. Those come from the US Census Bureau's international trade API,
    which requires an API key; this service is built to need none. The gap is declared at
    `/v1/sources`.

    **Not official.** Not affiliated with, endorsed by or certified by USITC, CBP or the Census
    Bureau. A duty estimate is an estimate: CBP determines the duty actually owed and
    classification is the importer's responsibility.
  license:
    name: MIT (server) — data is a work of the US Government, public domain
    url: https://apis.allanninal.dev/tariff/license.html
  contact:
    name: Allan Niñal
    url: https://www.linkedin.com/in/allanninal/
servers:
  - url: https://apis.allanninal.dev/tariff
    description: Production
tags:
  - name: Tariffs
    description: The complete Harmonized Tariff Schedule of the United States.
  - name: Duty
    description: Per-shipment duty estimation.
  - name: Reference
    description: Countries, trade programmes, CBP ports of entry and customs-rule citations.
  - name: Change
    description: Federal Register tariff actions and the HTS revision archive.
  - name: Service
    description: Health, version, plans, usage and provenance.

paths:
  /healthz:
    get:
      tags: [Service]
      summary: Liveness, dataset versions and declared coverage
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object } } } }

  /v1/tariffs:
    get:
      tags: [Tariffs]
      summary: Search the tariff schedule
      parameters:
        - { name: q, in: query, schema: { type: string }, description: "Free text over the full description path. EVERY token must match." }
        - { name: chapter, in: query, schema: { type: string, pattern: "^[0-9]{2}$" }, example: "84", description: "Two-digit HTS chapter. A well-formed chapter with no lines (77, which the HTS reserves) returns an empty page, not an error." }
        - { name: heading, in: query, schema: { type: string, pattern: "^[0-9]{4}$" }, example: "2204", description: "Four-digit HTS heading." }
        - { name: htsno, in: query, schema: { type: string }, description: "Prefix match; dots optional." }
        - { name: programme, in: query, schema: { type: string }, description: "Trade-programme letter, e.g. CL, KR, S." }
        - { name: rateKind, in: query, description: "The first four are computable. The next five are reported and never priced — filter on them to find, up front, which of your lines this service will refuse.", schema: { type: string, enum: [free, specific, compound, ad-valorem, cross-reference, sliding-scale, note-reference, conditional-free, apportioned, additive-reference, no-change] } }
        - { name: unit, in: query, schema: { type: string }, example: "kg", description: "Unit of quantity as the schedule states it, e.g. kg, liters, pr., doz., No." }
        - { name: classifiable, in: query, schema: { type: boolean }, description: "Only the 10-digit lines enterable on a customs entry." }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, default: 20 } }
        - { name: cursor, in: query, schema: { type: string } }
      responses:
        "200": { $ref: "#/components/responses/TariffList" }
        "400": { $ref: "#/components/responses/BadFilter" }

  /v1/tariffs/release:
    get:
      tags: [Tariffs]
      summary: Which HTS revision is live
      description: |
        Registered before `/v1/tariffs/{htsno}` so that `release` is not read as a tariff number.
        USITC serves only the current revision and publishes no archive of earlier ones.
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object } } } }

  /v1/tariffs/{htsno}:
    get:
      tags: [Tariffs]
      summary: One tariff line
      parameters:
        - { name: htsno, in: path, required: true, schema: { type: string }, example: "2204.21.50.60" }
      responses:
        "200":
          description: The line, with the rate that applies and where it came from.
          content: { application/json: { schema: { $ref: "#/components/schemas/TariffLine" } } }
        "400": { $ref: "#/components/responses/Problem" }
        "404": { $ref: "#/components/responses/Problem" }
        "409":
          description: The prefix matches more than one line. Candidates are listed; nothing is picked arbitrarily.
          content: { application/problem+json: { schema: { type: object } } }

  /v1/chapter99:
    get:
      tags: [Tariffs]
      summary: Chapter 99 additional-duty headings
      description: Listed so they can be checked. Never folded into a computed duty.
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 50 } }
        - { name: cursor, in: query, schema: { type: string } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object } } } }

  /v1/duty-estimate:
    post:
      tags: [Duty]
      summary: Estimate the duty on a shipment
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DutyRequest" }
            examples:
              per-kilogram:
                summary: A duty assessed per kilogram — the commonest shape in the schedule
                value: { htsno: "0102.29.40.24", quantity: 5000, quantityUnit: "kg", customsValueUsd: 40000, countryOfOrigin: "MX" }
              ad-valorem:
                summary: A percentage of customs value — no quantity needed at all
                value: { htsno: "6109.10.00.12", customsValueUsd: 18000, countryOfOrigin: "VN" }
              still-wine:
                summary: 1,000 L of French still wine — litres is still accepted as its own field
                value: { htsno: "2204.21.50.60", litres: 1000, customsValueUsd: 5000, countryOfOrigin: "FR" }
              fortified:
                summary: A compound rate with a proof-litre term, so alcohol content is required
                value: { htsno: "2204.30.00.00", litres: 100, customsValueUsd: 1000, alcoholByVolume: 18 }
              refused:
                summary: A rate this service reads but will not price — answers 200 with dutyUsd null and the reason
                value: { htsno: "1701.13.05.00", quantity: 20000, quantityUnit: "kg", customsValueUsd: 9000 }
      responses:
        "200":
          description: The estimate, with its arithmetic.
          content: { application/json: { schema: { $ref: "#/components/schemas/DutyEstimate" } } }
        "400": { $ref: "#/components/responses/Problem" }
        "404": { $ref: "#/components/responses/Problem" }
        "409": { $ref: "#/components/responses/Problem" }

  /v1/countries:
    get:
      tags: [Reference]
      summary: Schedule C countries and their trade programmes
      parameters:
        - { name: q, in: query, schema: { type: string } }
        - { name: iso2, in: query, schema: { type: string } }
        - { name: censusCode, in: query, schema: { type: string } }
        - { name: programme, in: query, schema: { type: string } }
        - { name: mapped, in: query, schema: { type: boolean } }
        - { name: limit, in: query, schema: { type: integer, default: 20 } }
        - { name: cursor, in: query, schema: { type: string } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/BadFilter" }

  /v1/countries/{code}:
    get:
      tags: [Reference]
      summary: One country, by ISO alpha-2, Schedule C code or name
      parameters:
        - { name: code, in: path, required: true, schema: { type: string }, example: "FR" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Country" } } } }
        "404": { $ref: "#/components/responses/Problem" }

  /v1/ports:
    get:
      tags: [Reference]
      summary: CBP customs districts and ports of entry
      description: |
        Schedule D. A district is a two-digit code, a port a four-digit one, and a port's first
        two digits are its district — both are in one list, told apart by `kind`.
      parameters:
        - { name: q, in: query, schema: { type: string }, example: "NEW", description: "Substring over code, port name and district name. Unlike /v1/tariffs this does NOT require every token: port names are short place names." }
        - { name: kind, in: query, schema: { type: string, enum: [district, port] } }
        - { name: district, in: query, schema: { type: string, pattern: "^[0-9]{1,2}$" }, example: "10", description: "Ports reporting to one district." }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, default: 20 } }
        - { name: cursor, in: query, schema: { type: string } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/BadFilter" }

  /v1/ports/{code}:
    get:
      tags: [Reference]
      summary: One district or port of entry
      description: A district also returns the ports that report to it.
      parameters:
        - { name: code, in: path, required: true, schema: { type: string }, example: "2704" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object } } } }
        "404": { $ref: "#/components/responses/Problem" }
        "409":
          description: A name matching more than one port — there are two Portlands. Candidates listed; nothing is picked.
          content: { application/problem+json: { schema: { type: object } } }

  /v1/notices:
    get:
      tags: [Change]
      summary: Federal Register tariff actions reaching wine
      parameters:
        - { name: q, in: query, schema: { type: string } }
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to, in: query, schema: { type: string, format: date } }
        - { name: type, in: query, schema: { type: string } }
        - { name: wineOnly, in: query, schema: { type: boolean } }
        - { name: limit, in: query, schema: { type: integer, default: 20 } }
        - { name: cursor, in: query, schema: { type: string } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/BadFilter" }

  /v1/customs-rules:
    get:
      tags: [Reference]
      summary: 19 CFR citations for entry, classification, liquidation and protest
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object } } } }

  /v1/changes:
    get:
      tags: [Change]
      summary: What changed since a date
      parameters:
        - { name: dataset, in: query, schema: { type: string, enum: [hts, countries, notices], default: hts } }
        - { name: since, in: query, required: true, schema: { type: string, format: date-time } }
        - { name: limit, in: query, schema: { type: integer, default: 50 } }
        - { name: cursor, in: query, schema: { type: string } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object } } } }
        "400": { $ref: "#/components/responses/Problem" }
        "402":
          description: Beyond this plan's history window.
          content: { application/problem+json: { schema: { type: object } } }
        "422":
          description: Before this service began retaining history. It will not answer with the current schedule dressed as history.
          content: { application/problem+json: { schema: { type: object } } }

  /v1/history/coverage:
    get:
      tags: [Change]
      summary: How far back the archive actually reaches
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object } } } }
        "503": { $ref: "#/components/responses/Problem" }

  /v1/sources:
    get:
      tags: [Service]
      summary: Every upstream — and every source evaluated and rejected, with the reason
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object } } } }

  /v1/plans:
    get: { tags: [Service], summary: The plan catalogue, responses: { "200": { description: OK, content: { application/json: { schema: { type: object } } } } } }
  /v1/version:
    get: { tags: [Service], summary: Contract, dataset and HTS revision versions, responses: { "200": { description: OK, content: { application/json: { schema: { type: object } } } } } }
  /v1/usage:
    get: { tags: [Service], summary: Your budget and what you have used today, responses: { "200": { description: OK, content: { application/json: { schema: { type: object } } } } } }

components:
  securitySchemes:
    bearerKey:
      type: http
      scheme: bearer
      description: Optional. A key raises your budget; it never unlocks data.
  responses:
    Problem:
      description: RFC 9457 problem document.
      content: { application/problem+json: { schema: { type: object } } }
    BadFilter:
      description: A filter was invalid. The reason is returned; the query is never silently run unfiltered.
      content: { application/json: { schema: { type: object, properties: { error: { type: string }, details: { type: array, items: { type: string } } } } } }
    TariffList:
      description: A page of tariff lines, with provenance and plan.
      content:
        application/json:
          schema:
            type: object
            properties:
              total: { type: integer }
              items: { type: array, items: { $ref: "#/components/schemas/TariffLine" } }
              nextCursor: { type: [string, "null"] }
              datasetVersions: { type: object }
              htsRelease: { type: string }
              licence: { type: string }
              attribution: { type: array, items: { type: string } }
  schemas:
    TariffLine:
      $ref: https://apis.allanninal.dev/tariff/api/v1/schemas/tariff-line.schema.json
    Country:
      $ref: https://apis.allanninal.dev/tariff/api/v1/schemas/country.schema.json
    DutyRequest:
      type: object
      required: [htsno, customsValueUsd]
      description: |
        Only `htsno` and `customsValueUsd` are required. Quantity is not, because most of the
        schedule is ad valorem and needs none — and when a rate DOES need one, the estimate
        refuses and names the unit it needs, which is a better answer than a 400 that guesses
        at what you meant.
      properties:
        htsno: { type: string, description: "HTS number, dots optional." }
        quantity: { type: number, minimum: 0, description: "How much, in `quantityUnit`." }
        quantityUnit: { type: string, example: "kg", description: "The unit the duty is assessed in: kg, liter, pair, dozen, each, barrel, m3, m2, gross, thousand, clean kg, metric ton, gram, cm3. The spellings the schedule itself uses (pr., doz., bbl) are accepted." }
        quantities: { type: object, additionalProperties: { type: number }, example: { each: 500, kg: 120 }, description: "For a compound rate assessed on two units at once. Merged with quantity/quantityUnit." }
        litres: { type: number, minimum: 0, description: "Accepted as its own field for callers written against this service when it covered wine only. Equivalent to quantity with quantityUnit liter." }
        customsValueUsd: { type: number, minimum: 0 }
        countryOfOrigin: { type: string, description: "ISO alpha-2, Schedule C code or name. Determines which trade programmes are offered." }
        alcoholByVolume: { type: number, description: "Required only for compound rates carrying a proof-litre term. Without it the estimate refuses rather than guessing." }
        proofLitres: { type: number, description: "Supply directly instead of alcoholByVolume if you already have it." }
        column2: { type: boolean, description: "Use column 2 — the rate for countries denied normal trade relations." }
    DutyEstimate:
      type: object
      properties:
        estimate: { type: boolean, const: true }
        htsno: { type: string }
        rate: { type: string, description: "The published rate string this was computed from." }
        rateKind: { type: string }
        dutyUsd: { type: [number, "null"], description: "null, never 0, when the rate could not be applied." }
        applicable: { type: boolean }
        notApplicableBecause: { type: string, description: "Why no number was produced. Present whenever applicable is false." }
        requiredUnit: { type: string, description: "Present when the refusal was for a missing quantity: the canonical unit to send as quantityUnit." }
        quantities: { type: object, additionalProperties: { type: number }, description: "The shipment as the estimator read it, after merging quantity, quantities, litres and the proof litres derived from alcoholByVolume." }
        working: { type: array, items: { type: object, properties: { basis: { type: string }, amountUsd: { type: number } } } }
        effectiveAdValoremRate: { type: [number, "null"], description: "Duty as a share of customs value. This is what moves when a specific rate meets a different unit value." }
        specialRatesAvailable: { type: array, description: "Trade-programme rates the origin country could claim IF the goods qualify. Never applied to dutyUsd." }
        chapter99Candidates: { type: array, description: "Additional-duty headings to check. Always applied:false." }
        excludes: { type: array, items: { type: string } }
        disclaimer: { type: string }
        derived: { type: boolean, const: true }
security:
  - {}
  - bearerKey: []
