> ## Documentation Index
> Fetch the complete documentation index at: https://apidocs.taxwire.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Using the Products APIs

> How to create and manage products, assign tax codes, and use products as presets in calculations.

Products let you maintain a catalog of what you sell, with tax codes attached. Once a product is saved, you reference it as a **preset** in calculations and transactions instead of repeating tax attributes on every line item.

Two supporting endpoints feed into this workflow:

* [`GET /product-tax-codes`](/api-reference/product-tax-codes/list) — the full list of tax codes you can assign to a product.
* [`GET /product-attributes`](/api-reference/product-attributes/list) — modifiers that refine how a product is taxed (rental, downloaded, B2B, etc.).

***

## Simple lifecycle

Use this when your products map cleanly to a single tax code and you don't need attribute-level refinement.

### 1. Create the product

Send a `POST /products` with the minimum required fields — `source_id` and `name` — and optionally a `tax_codes` array. Omit `tax_codes` to inherit your organization's default tax code.

<CodeGroup>
  ```shell cURL theme={null}
  curl --request POST \
    --url https://api.taxwire.com/products \
    --header 'Content-Type: application/json' \
    --header 'Taxwire-Version: hydrogen.2025-04-15' \
    --header 'X-API-Key: YOUR_API_KEY' \
    --data '{
      "source_id": "SKU-1001",
      "name": "Organic Coffee Beans",
      "description": "12 oz bag of roasted coffee beans",
      "tax_codes": ["TANGIBLE_PERSONAL_PROPERTY.FOOD"]
    }'
  ```

  ```python Python theme={null}
  import requests

  url = "https://api.taxwire.com/products"
  headers = {
      "Taxwire-Version": "hydrogen.2025-04-15",
      "Content-Type": "application/json",
      "X-API-Key": "YOUR_API_KEY"
  }
  payload = {
      "source_id": "SKU-1001",
      "name": "Organic Coffee Beans",
      "description": "12 oz bag of roasted coffee beans",
      "tax_codes": ["TANGIBLE_PERSONAL_PROPERTY.FOOD"]
  }
  response = requests.post(url, json=payload, headers=headers)
  print(response.json())
  ```

  ```typescript TypeScript theme={null}
  const response = await fetch("https://api.taxwire.com/products", {
    method: "POST",
    headers: {
      "Taxwire-Version": "hydrogen.2025-04-15",
      "Content-Type": "application/json",
      "X-API-Key": "YOUR_API_KEY",
    },
    body: JSON.stringify({
      source_id: "SKU-1001",
      name: "Organic Coffee Beans",
      description: "12 oz bag of roasted coffee beans",
      tax_codes: ["TANGIBLE_PERSONAL_PROPERTY.FOOD"],
    }),
  });
  const data = await response.json();
  console.log(data);
  ```
</CodeGroup>

The response includes Taxwire's stable `id` for the product. Save it — you'll use it as a preset.

```json Response (201) theme={null}
{
  "id": "prd_01J9Z8Q4E7K2M3N4P5R6S7T8U9",
  "source_id": "SKU-1001",
  "source": "taxwire",
  "source_account_id": "__default__",
  "name": "Organic Coffee Beans",
  "tax_codes": ["TANGIBLE_PERSONAL_PROPERTY.FOOD"],
  "resolved_tax_codes": ["TANGIBLE_PERSONAL_PROPERTY.FOOD"],
  ...
}
```

<Note>
  `resolved_tax_codes` is what Taxwire actually uses during a calculation. When `tax_codes` is empty, `resolved_tax_codes` reflects your organization's default. When you assign codes explicitly, both fields match.
</Note>

### 2. Reference the product as a preset in a calculation

Pass the product's `id` in `product.preset` on a line item. Taxwire loads the tax codes from the saved product automatically.

```json Line item using a product preset theme={null}
{
  "id": "item_1",
  "quantity": "2",
  "unit_price": "1500",
  "product": {
    "preset": {
      "id": "prd_01J9Z8Q4E7K2M3N4P5R6S7T8U9",
      "source": "taxwire"
    }
  }
}
```

### 3. Update the product when it changes

`PATCH /products/{product_id}` is a partial update — omit any field you don't want to touch.

<CodeGroup>
  ```shell cURL theme={null}
  curl --request PATCH \
    --url https://api.taxwire.com/products/prd_01J9Z8Q4E7K2M3N4P5R6S7T8U9 \
    --header 'Content-Type: application/json' \
    --header 'Taxwire-Version: hydrogen.2025-04-15' \
    --header 'X-API-Key: YOUR_API_KEY' \
    --data '{
      "name": "Organic Coffee Beans – Dark Roast",
      "tax_codes": ["TANGIBLE_PERSONAL_PROPERTY.FOOD.COFFEE"]
    }'
  ```

  ```python Python theme={null}
  import requests

  product_id = "prd_01J9Z8Q4E7K2M3N4P5R6S7T8U9"
  url = f"https://api.taxwire.com/products/{product_id}"
  headers = {
      "Taxwire-Version": "hydrogen.2025-04-15",
      "Content-Type": "application/json",
      "X-API-Key": "YOUR_API_KEY"
  }
  payload = {
      "name": "Organic Coffee Beans – Dark Roast",
      "tax_codes": ["TANGIBLE_PERSONAL_PROPERTY.FOOD.COFFEE"]
  }
  response = requests.patch(url, json=payload, headers=headers)
  print(response.json())
  ```

  ```typescript TypeScript theme={null}
  const productId = "prd_01J9Z8Q4E7K2M3N4P5R6S7T8U9";
  const response = await fetch(`https://api.taxwire.com/products/${productId}`, {
    method: "PATCH",
    headers: {
      "Taxwire-Version": "hydrogen.2025-04-15",
      "Content-Type": "application/json",
      "X-API-Key": "YOUR_API_KEY",
    },
    body: JSON.stringify({
      name: "Organic Coffee Beans – Dark Roast",
      tax_codes: ["TANGIBLE_PERSONAL_PROPERTY.FOOD.COFFEE"],
    }),
  });
  const data = await response.json();
  console.log(data);
  ```
</CodeGroup>

To clear the tax code override and fall back to the organization default, send `"tax_codes": []`.

***

## Complex lifecycle

Use this when a product's taxability depends on *how* it is delivered or used — downloaded vs. shipped, rented vs. sold, B2B vs. B2C. Product attributes express those distinctions.

### 1. Find the right base tax code

Call `GET /product-tax-codes` to browse available codes.

<CodeGroup>
  ```shell cURL theme={null}
  curl --request GET \
    --url https://api.taxwire.com/product-tax-codes \
    --header 'Taxwire-Version: hydrogen.2025-04-15' \
    --header 'X-API-Key: YOUR_API_KEY'
  ```

  ```python Python theme={null}
  import requests

  url = "https://api.taxwire.com/product-tax-codes"
  headers = {
      "Taxwire-Version": "hydrogen.2025-04-15",
      "X-API-Key": "YOUR_API_KEY"
  }
  response = requests.get(url, headers=headers)
  print(response.json())
  ```

  ```typescript TypeScript theme={null}
  const response = await fetch("https://api.taxwire.com/product-tax-codes", {
    headers: {
      "Taxwire-Version": "hydrogen.2025-04-15",
      "X-API-Key": "YOUR_API_KEY",
    },
  });
  const data = await response.json();
  console.log(data);
  ```
</CodeGroup>

```json Response (excerpt) theme={null}
{
  "data": [
    {
      "code": "DIGITAL_PROPERTY.DIGITAL_SOFTWARE",
      "description": "Software sold as a digital product."
    },
    {
      "code": "DIGITAL_SERVICES.SOFTWARE_AS_A_SERVICE",
      "description": "Downloadable or cloud-hosted software sold as a service."
    }
  ]
}
```

See also the [product tax code hierarchy](/resources/product-tax-codes/product-hierarchy) for the full tree.

### 2. Find applicable attributes

Call `GET /product-attributes` to see all available modifiers. Pass `?ptc=` to filter to only the attributes that apply to your chosen code.

<CodeGroup>
  ```shell cURL theme={null}
  curl --request GET \
    --url 'https://api.taxwire.com/product-attributes?ptc=DIGITAL_PROPERTY.DIGITAL_SOFTWARE' \
    --header 'Taxwire-Version: hydrogen.2025-04-15' \
    --header 'X-API-Key: YOUR_API_KEY'
  ```

  ```python Python theme={null}
  import requests

  url = "https://api.taxwire.com/product-attributes"
  headers = {
      "Taxwire-Version": "hydrogen.2025-04-15",
      "X-API-Key": "YOUR_API_KEY"
  }
  params = {"ptc": "DIGITAL_PROPERTY.DIGITAL_SOFTWARE"}
  response = requests.get(url, params=params, headers=headers)
  print(response.json())
  ```

  ```typescript TypeScript theme={null}
  const params = new URLSearchParams({ ptc: "DIGITAL_PROPERTY.DIGITAL_SOFTWARE" });
  const response = await fetch(`https://api.taxwire.com/product-attributes?${params}`, {
    headers: {
      "Taxwire-Version": "hydrogen.2025-04-15",
      "X-API-Key": "YOUR_API_KEY",
    },
  });
  const data = await response.json();
  console.log(data);
  ```
</CodeGroup>

```json Response (excerpt) theme={null}
{
  "data": [
    {
      "code": "_DOWNLOADED",
      "name": "Downloaded",
      "description": "The software is delivered as a download.",
      "group": { "key": "delivery_method", "name": "Delivery method", "exclusive": true },
      "ptcs": ["DIGITAL_PROPERTY"]
    },
    {
      "code": "_LOAD_AND_LEAVE",
      "name": "Load and leave",
      "description": "The software is installed on-site by the vendor.",
      "group": { "key": "delivery_method", "name": "Delivery method", "exclusive": true },
      "ptcs": ["DIGITAL_PROPERTY"]
    }
  ]
}
```

<Note>
  Attributes within the same group can be mutually exclusive — check the `group.exclusive` flag. An attribute appends to the code as a trailing `.`-separated component: `DIGITAL_PROPERTY.DIGITAL_SOFTWARE._DOWNLOADED`.

  An empty `ptcs` array means the attribute applies to every product, regardless of tax code.
</Note>

### 3. Compose the full tax code

Append the chosen attribute to the base code:

```
DIGITAL_PROPERTY.DIGITAL_SOFTWARE._DOWNLOADED
```

Attributes always trail the category path. Multiple attributes chain further: `DIGITAL_PROPERTY.DIGITAL_SOFTWARE._DOWNLOADED._B2B`. See [Tax attributes](/api-reference/guides/tax_attributes) for full composition rules.

### 4. Create the product with the composed code

<CodeGroup>
  ```shell cURL theme={null}
  curl --request POST \
    --url https://api.taxwire.com/products \
    --header 'Content-Type: application/json' \
    --header 'Taxwire-Version: hydrogen.2025-04-15' \
    --header 'X-API-Key: YOUR_API_KEY' \
    --data '{
      "source_id": "SW-DOWNLOAD-PRO",
      "name": "ProSuite – Download Edition",
      "description": "Desktop software delivered as a digital download.",
      "source_category": "Software > Desktop",
      "tax_codes": ["DIGITAL_PROPERTY.DIGITAL_SOFTWARE._DOWNLOADED"],
      "source_updated_at": "2026-09-01T00:00:00Z"
    }'
  ```

  ```python Python theme={null}
  import requests

  url = "https://api.taxwire.com/products"
  headers = {
      "Taxwire-Version": "hydrogen.2025-04-15",
      "Content-Type": "application/json",
      "X-API-Key": "YOUR_API_KEY"
  }
  payload = {
      "source_id": "SW-DOWNLOAD-PRO",
      "name": "ProSuite – Download Edition",
      "description": "Desktop software delivered as a digital download.",
      "source_category": "Software > Desktop",
      "tax_codes": ["DIGITAL_PROPERTY.DIGITAL_SOFTWARE._DOWNLOADED"],
      "source_updated_at": "2026-09-01T00:00:00Z"
  }
  response = requests.post(url, json=payload, headers=headers)
  print(response.json())
  ```

  ```typescript TypeScript theme={null}
  const response = await fetch("https://api.taxwire.com/products", {
    method: "POST",
    headers: {
      "Taxwire-Version": "hydrogen.2025-04-15",
      "Content-Type": "application/json",
      "X-API-Key": "YOUR_API_KEY",
    },
    body: JSON.stringify({
      source_id: "SW-DOWNLOAD-PRO",
      name: "ProSuite – Download Edition",
      description: "Desktop software delivered as a digital download.",
      source_category: "Software > Desktop",
      tax_codes: ["DIGITAL_PROPERTY.DIGITAL_SOFTWARE._DOWNLOADED"],
      source_updated_at: "2026-09-01T00:00:00Z",
    }),
  });
  const data = await response.json();
  console.log(data);
  ```
</CodeGroup>

### 5. Reference the product as a preset

Same as the simple lifecycle — use `product.preset` on any line item.

```json Line item theme={null}
{
  "id": "item_1",
  "quantity": "1",
  "unit_price": "29900",
  "product": {
    "preset": {
      "id": "prd_DOWNLOAD_PRO_ID",
      "source": "taxwire"
    }
  }
}
```

### 6. Update the tax code when delivery method changes

If the same software starts shipping on USB drives instead, update the attribute:

<CodeGroup>
  ```shell cURL theme={null}
  curl --request PATCH \
    --url https://api.taxwire.com/products/prd_DOWNLOAD_PRO_ID \
    --header 'Content-Type: application/json' \
    --header 'Taxwire-Version: hydrogen.2025-04-15' \
    --header 'X-API-Key: YOUR_API_KEY' \
    --data '{
      "tax_codes": ["DIGITAL_PROPERTY.DIGITAL_SOFTWARE._LOAD_AND_LEAVE"],
      "source_updated_at": "2026-10-01T00:00:00Z"
    }'
  ```

  ```python Python theme={null}
  import requests

  product_id = "prd_DOWNLOAD_PRO_ID"
  url = f"https://api.taxwire.com/products/{product_id}"
  headers = {
      "Taxwire-Version": "hydrogen.2025-04-15",
      "Content-Type": "application/json",
      "X-API-Key": "YOUR_API_KEY"
  }
  payload = {
      "tax_codes": ["DIGITAL_PROPERTY.DIGITAL_SOFTWARE._LOAD_AND_LEAVE"],
      "source_updated_at": "2026-10-01T00:00:00Z"
  }
  response = requests.patch(url, json=payload, headers=headers)
  print(response.json())
  ```

  ```typescript TypeScript theme={null}
  const productId = "prd_DOWNLOAD_PRO_ID";
  const response = await fetch(`https://api.taxwire.com/products/${productId}`, {
    method: "PATCH",
    headers: {
      "Taxwire-Version": "hydrogen.2025-04-15",
      "Content-Type": "application/json",
      "X-API-Key": "YOUR_API_KEY",
    },
    body: JSON.stringify({
      tax_codes: ["DIGITAL_PROPERTY.DIGITAL_SOFTWARE._LOAD_AND_LEAVE"],
      source_updated_at: "2026-10-01T00:00:00Z",
    }),
  });
  const data = await response.json();
  console.log(data);
  ```
</CodeGroup>

<Warning>
  `source_updated_at` must move forward — sending a timestamp older than the current value is rejected with a `422`. Omit it if you don't track source-system change timestamps.
</Warning>

***

## Key concepts

| Concept | Detail |
| - | - |
| `source` + `source_id` | Your external identifier for a product. Use `source: "taxwire"` for products you manage directly through this API. |
| `tax_codes` | The codes you assign. Empty means "use the org default". |
| `resolved_tax_codes` | What Taxwire actually uses in a calculation. Never empty. |
| Attributes | Trailing `.`-separated modifiers that refine a base tax code. Compose them once on the product; every line item using the preset inherits them. |
| PATCH semantics | Partial update — omit a field to leave it unchanged. `"tax_codes": []` explicitly reverts to the org default. `"description": null` clears the description. |

***

## Putting it all together

Complete sync of 5 products. The script:

1. Fetches the full PTC catalog (paginated) and matches PTCs to your product types by keyword.
2. Fetches attributes for the 3 matched PTCs in parallel.
3. Resolves specific attributes by code — fails loudly if not found.
4. Builds and creates all 5 products in parallel.

| Product | Tax code strategy |
| - | - |
| Office Setup Service | No `tax_codes` — inherits org default |
| Organic Coffee Beans | `TANGIBLE_PERSONAL_PROPERTY.FOOD` — PTC only |
| Cloud Backup – Starter | `DIGITAL_SERVICES.SOFTWARE_AS_A_SERVICE` — PTC only |
| ProSuite – Download Edition | `DIGITAL_PROPERTY.DIGITAL_SOFTWARE._DOWNLOADED` — PTC + attribute |
| ProSuite – On-site Install | `DIGITAL_PROPERTY.DIGITAL_SOFTWARE._LOAD_AND_LEAVE` — PTC + attribute |

<CodeGroup>
  ```python Python theme={null}
  import threading
  import time
  import requests
  from concurrent.futures import ThreadPoolExecutor, as_completed

  BASE_URL = "https://api.taxwire.com"
  HEADERS = {
      "Taxwire-Version": "hydrogen.2025-04-15",
      "Content-Type": "application/json",
      "X-API-Key": "YOUR_API_KEY",
  }


  class _RateLimiter:
      """Token-bucket limiter, thread-safe. Shared across all endpoints."""
      def __init__(self, rate: int) -> None:
          self._rate = rate
          self._tokens = float(rate)
          self._last = time.monotonic()
          self._lock = threading.Lock()

      def acquire(self) -> None:
          with self._lock:
              now = time.monotonic()
              self._tokens = min(self._rate, self._tokens + (now - self._last) * self._rate)
              self._last = now
              if self._tokens < 1:
                  time.sleep((1 - self._tokens) / self._rate)
                  self._tokens = 0.0
              else:
                  self._tokens -= 1.0

  LIMITER = _RateLimiter(rate=10)  # 10 req/s across all endpoints


  # --- helpers ---

  def fetch_all_ptcs() -> list[dict]:
      """Page through GET /product-tax-codes and return the full catalog."""
      ptcs: list[dict] = []
      params: dict = {"limit": 1000}
      while True:
          LIMITER.acquire()
          resp = requests.get(f"{BASE_URL}/product-tax-codes", params=params, headers=HEADERS)
          resp.raise_for_status()
          page = resp.json()
          ptcs.extend(page["data"])
          if not page["has_more"]:
              break
          params["starting_after"] = page["data"][-1]["code"]
      return ptcs


  def find_ptc(ptcs: list[dict], keywords: list[str]) -> str:
      """Return the first PTC whose code or description contains any keyword."""
      for ptc in ptcs:
          haystack = f"{ptc['code']} {ptc.get('description') or ''}".lower()
          if any(kw.lower() in haystack for kw in keywords):
              return ptc["code"]
      raise ValueError(f"No PTC matched keywords {keywords!r}")


  def fetch_attributes(ptc: str) -> tuple[str, list[dict]]:
      LIMITER.acquire()
      resp = requests.get(
          f"{BASE_URL}/product-attributes",
          params={"ptc": ptc, "limit": 1000},
          headers=HEADERS,
      )
      resp.raise_for_status()
      return ptc, resp.json()["data"]


  def require_attr(attrs: list[dict], code: str, ptc: str) -> str:
      """Return the attribute code or raise — never silently fall back."""
      if any(a["code"] == code for a in attrs):
          return code
      available = [a["code"] for a in attrs]
      raise ValueError(f"Attribute {code!r} not available for {ptc}. Available: {available}")


  def create_product(payload: dict) -> dict:
      LIMITER.acquire()
      resp = requests.post(f"{BASE_URL}/products", json=payload, headers=HEADERS)
      resp.raise_for_status()
      return resp.json()


  # --- step 1: discover PTCs ---

  print("Fetching PTC catalog...")
  all_ptcs = fetch_all_ptcs()
  print(f"  {len(all_ptcs)} codes loaded.")

  food_ptc     = find_ptc(all_ptcs, ["food"])
  software_ptc = find_ptc(all_ptcs, ["digital_software"])
  saas_ptc     = find_ptc(all_ptcs, ["software_as_a_service"])
  print(f"  Matched: {food_ptc}, {software_ptc}, {saas_ptc}")

  # --- step 2: fetch attributes for all 3 PTCs in parallel ---

  print("\nFetching attributes...")
  attributes_by_ptc: dict[str, list[dict]] = {}
  with ThreadPoolExecutor() as pool:
      futures = {
          pool.submit(fetch_attributes, ptc): ptc
          for ptc in [food_ptc, software_ptc, saas_ptc]
      }
      for future in as_completed(futures):
          ptc, attrs = future.result()
          attributes_by_ptc[ptc] = attrs
          print(f"  {ptc}: {len(attrs)} attributes")

  # Resolve the specific attributes we need — fail loudly if missing
  software_attrs = attributes_by_ptc[software_ptc]
  downloaded     = require_attr(software_attrs, "_DOWNLOADED",    software_ptc)
  load_and_leave = require_attr(software_attrs, "_LOAD_AND_LEAVE", software_ptc)

  # --- step 3: build the product catalog ---

  products = [
      # Inherits org default — no tax_codes
      {
          "source_id": "SVC-SETUP-001",
          "name": "Office Setup Service",
          "description": "On-site hardware setup and configuration.",
      },
      # PTC only
      {
          "source_id": "SKU-COFFEE-001",
          "name": "Organic Coffee Beans",
          "description": "12 oz bag of roasted Arabica beans.",
          "tax_codes": [food_ptc],
      },
      # PTC only
      {
          "source_id": "SVC-BACKUP-001",
          "name": "Cloud Backup – Starter",
          "description": "Automated cloud backup, 100 GB.",
          "tax_codes": [saas_ptc],
      },
      # PTC + attribute
      {
          "source_id": "SW-PROSUITE-DL",
          "name": "ProSuite – Download Edition",
          "description": "Desktop software delivered as a digital download.",
          "tax_codes": [f"{software_ptc}.{downloaded}"],
      },
      # PTC + attribute
      {
          "source_id": "SW-PROSUITE-ONSITE",
          "name": "ProSuite – On-site Install",
          "description": "Desktop software installed on-site by a technician.",
          "tax_codes": [f"{software_ptc}.{load_and_leave}"],
      },
  ]

  # --- step 4: create all products in parallel ---

  print("\nCreating products...")
  results: list[dict] = [None] * len(products)
  with ThreadPoolExecutor() as pool:
      futures = {pool.submit(create_product, p): i for i, p in enumerate(products)}
      for future in as_completed(futures):
          i = futures[future]
          result = future.result()
          results[i] = result
          print(
              f"  ✓ {result['name']}"
              f"\n      id={result['id']}"
              f"\n      resolved_tax_codes={result['resolved_tax_codes']}"
          )

  print(f"\n{len(results)} products created.")
  ```

  ```typescript TypeScript theme={null}
  const BASE_URL = "https://api.taxwire.com";
  const HEADERS = {
    "Taxwire-Version": "hydrogen.2025-04-15",
    "Content-Type": "application/json",
    "X-API-Key": "YOUR_API_KEY",
  };

  // Token-bucket limiter. JS is single-threaded so no mutex needed.
  class RateLimiter {
    private tokens: number;
    private last = Date.now();

    constructor(private readonly rate: number) {
      this.tokens = rate;
    }

    acquire(): Promise<void> {
      const now = Date.now();
      this.tokens = Math.min(this.rate, this.tokens + ((now - this.last) / 1000) * this.rate);
      this.last = now;
      if (this.tokens >= 1) {
        this.tokens -= 1;
        return Promise.resolve();
      }
      const delay = ((1 - this.tokens) / this.rate) * 1000;
      this.tokens = 0;
      return new Promise((resolve) => setTimeout(resolve, delay));
    }
  }

  const limiter = new RateLimiter(10); // 10 req/s across all endpoints

  interface PtcItem {
    code: string;
    description: string | null;
  }

  interface ProductAttribute {
    code: string;
    name: string;
    description: string;
  }

  interface CreateProductPayload {
    source_id: string;
    name: string;
    description?: string;
    tax_codes?: string[];
  }

  interface ProductResource {
    id: string;
    name: string;
    resolved_tax_codes: string[];
  }

  // --- helpers ---

  async function fetchAllPtcs(): Promise<PtcItem[]> {
    const ptcs: PtcItem[] = [];
    let startingAfter: string | undefined;
    do {
      await limiter.acquire();
      const params = new URLSearchParams({ limit: "1000" });
      if (startingAfter) params.set("starting_after", startingAfter);
      const res = await fetch(`${BASE_URL}/product-tax-codes?${params}`, { headers: HEADERS });
      if (!res.ok) throw new Error(`GET /product-tax-codes failed: ${res.status}`);
      const page = await res.json();
      ptcs.push(...page.data);
      startingAfter = page.has_more ? page.data.at(-1).code : undefined;
    } while (startingAfter);
    return ptcs;
  }

  function findPtc(ptcs: PtcItem[], keywords: string[]): string {
    const match = ptcs.find((ptc) => {
      const haystack = `${ptc.code} ${ptc.description ?? ""}`.toLowerCase();
      return keywords.some((kw) => haystack.includes(kw.toLowerCase()));
    });
    if (!match) throw new Error(`No PTC matched keywords: ${keywords.join(", ")}`);
    return match.code;
  }

  async function fetchAttributes(ptc: string): Promise<ProductAttribute[]> {
    await limiter.acquire();
    const params = new URLSearchParams({ ptc, limit: "1000" });
    const res = await fetch(`${BASE_URL}/product-attributes?${params}`, { headers: HEADERS });
    if (!res.ok) throw new Error(`GET /product-attributes?ptc=${ptc} failed: ${res.status}`);
    const { data } = await res.json();
    return data;
  }

  function requireAttr(attrs: ProductAttribute[], code: string, ptc: string): string {
    if (attrs.some((a) => a.code === code)) return code;
    const available = attrs.map((a) => a.code);
    throw new Error(`Attribute "${code}" not available for ${ptc}. Available: ${available.join(", ")}`);
  }

  async function createProduct(payload: CreateProductPayload): Promise<ProductResource> {
    await limiter.acquire();
    const res = await fetch(`${BASE_URL}/products`, {
      method: "POST",
      headers: HEADERS,
      body: JSON.stringify(payload),
    });
    if (!res.ok) throw new Error(`Failed to create "${payload.name}": ${res.status}`);
    return res.json();
  }

  // --- step 1: discover PTCs ---

  console.log("Fetching PTC catalog...");
  const allPtcs = await fetchAllPtcs();
  console.log(`  ${allPtcs.length} codes loaded.`);

  const foodPtc     = findPtc(allPtcs, ["food"]);
  const softwarePtc = findPtc(allPtcs, ["digital_software"]);
  const saasPtc     = findPtc(allPtcs, ["software_as_a_service"]);
  console.log(`  Matched: ${foodPtc}, ${softwarePtc}, ${saasPtc}`);

  // --- step 2: fetch attributes for all 3 PTCs in parallel ---

  console.log("\nFetching attributes...");
  const [foodAttrs, softwareAttrs, saasAttrs] = await Promise.all([
    fetchAttributes(foodPtc),
    fetchAttributes(softwarePtc),
    fetchAttributes(saasPtc),
  ]);
  console.log(`  ${foodPtc}: ${foodAttrs.length} attributes`);
  console.log(`  ${softwarePtc}: ${softwareAttrs.length} attributes`);
  console.log(`  ${saasPtc}: ${saasAttrs.length} attributes`);

  // Resolve the specific attributes we need — fail loudly if missing
  const downloaded   = requireAttr(softwareAttrs, "_DOWNLOADED",    softwarePtc);
  const loadAndLeave = requireAttr(softwareAttrs, "_LOAD_AND_LEAVE", softwarePtc);

  // --- step 3: build the product catalog ---

  const products: CreateProductPayload[] = [
    // Inherits org default — no tax_codes
    {
      source_id: "SVC-SETUP-001",
      name: "Office Setup Service",
      description: "On-site hardware setup and configuration.",
    },
    // PTC only
    {
      source_id: "SKU-COFFEE-001",
      name: "Organic Coffee Beans",
      description: "12 oz bag of roasted Arabica beans.",
      tax_codes: [foodPtc],
    },
    // PTC only
    {
      source_id: "SVC-BACKUP-001",
      name: "Cloud Backup – Starter",
      description: "Automated cloud backup, 100 GB.",
      tax_codes: [saasPtc],
    },
    // PTC + attribute
    {
      source_id: "SW-PROSUITE-DL",
      name: "ProSuite – Download Edition",
      description: "Desktop software delivered as a digital download.",
      tax_codes: [`${softwarePtc}.${downloaded}`],
    },
    // PTC + attribute
    {
      source_id: "SW-PROSUITE-ONSITE",
      name: "ProSuite – On-site Install",
      description: "Desktop software installed on-site by a technician.",
      tax_codes: [`${softwarePtc}.${loadAndLeave}`],
    },
  ];

  // --- step 4: create all products in parallel ---

  console.log("\nCreating products...");
  const created = await Promise.all(products.map(createProduct));
  for (const result of created) {
    console.log(
      `  ✓ ${result.name}\n` +
      `      id=${result.id}\n` +
      `      resolved_tax_codes=${JSON.stringify(result.resolved_tax_codes)}`
    );
  }
  console.log(`\n${created.length} products created.`);
  ```
</CodeGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.