GET /product-tax-codes— the full list of tax codes you can assign to a product.GET /product-attributes— 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 aPOST /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.
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"]
}'
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())
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);
id for the product. Save it — you’ll use it as a preset.
Response (201)
{
"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"],
...
}
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.2. Reference the product as a preset in a calculation
Pass the product’sid in product.preset on a line item. Taxwire loads the tax codes from the saved product automatically.
Line item using a product preset
{
"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.
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"]
}'
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())
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);
"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
CallGET /product-tax-codes to browse available codes.
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'
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())
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);
Response (excerpt)
{
"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."
}
]
}
2. Find applicable attributes
CallGET /product-attributes to see all available modifiers. Pass ?ptc= to filter to only the attributes that apply to your chosen code.
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'
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())
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);
Response (excerpt)
{
"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"]
}
]
}
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.3. Compose the full tax code
Append the chosen attribute to the base code:DIGITAL_PROPERTY.DIGITAL_SOFTWARE._DOWNLOADED
DIGITAL_PROPERTY.DIGITAL_SOFTWARE._DOWNLOADED._B2B. See Tax attributes for full composition rules.
4. Create the product with the composed code
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"
}'
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())
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);
5. Reference the product as a preset
Same as the simple lifecycle — useproduct.preset on any line item.
Line item
{
"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: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"
}'
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())
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);
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.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:- Fetches the full PTC catalog (paginated) and matches PTCs to your product types by keyword.
- Fetches attributes for the 3 matched PTCs in parallel.
- Resolves specific attributes by code — fails loudly if not found.
- 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 |
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.")
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.`);