Every customs declaration repeats the same facts about the same goods: description, origin, HS codes, weights, materials, dangerous goods. A product stores them once. Items reference it, and shipments inherit from it. Product records are synchronized from where they actually live: an e-commerce platform, an ERP, a PIM, or a 3PL’s WMS. The module is products. It is required in 3PL mode and optional elsewhere (chapter 12).

1. The product object

2. CRUD

  • Optimistic concurrency. Send If-Match: 7 (the version) on update. A stale version returns 409 state_error / version_conflict, with the current object in error.details. This prevents two sync sources from silently overwriting each other.
  • status is active, archived, or draft (incomplete trade data: allowed in drafts, and rejected at purchase when required).
  • The search query -hs_codes.import.GB:* finds products missing a UK import code, which is useful before opening a new corridor.

3. Sync

  • match is external_id, sku, or id. A match updates, and no match creates. The response is the product, with system_metadata.upsert: "created" | "updated" | "unchanged".
  • Field ownership. fields_owned says which fields this source is authoritative for. Fields it does not own are never overwritten by it. That lets an e-commerce platform own titles and prices, while the trade team owns HS codes and origin, with no last-writer-wins damage.
  • Bulk. POST /v1/batches with operation: "products.upsert" (up to 1000), or POST /v1/products/import with a CSV file and a column mapping.
  • Incremental pull. GET /v1/products?updated[gte]=… with cursor pagination, or product.* webhooks, keep downstream systems in sync.
  • Export. POST /v1/exports with resource: "products" (chapter 10 §8).

4. Items that reference products

Any Item may set product:
Resolution at create and update time (normative):
  1. The product’s fields, merged over its parent’s (for variants), fill every item field that the item leaves unset.
  2. import_hs_code is taken from hs_codes.import[ship_to.address.country], then "*". export_hs_code is taken from hs_codes.export[ship_from.address.country].
  3. Fields set on the item always win. Per-shipment overrides are normal, such as a discounted unit_value.
  4. The resolved values are written onto the item. At purchase they are snapshotted, so later product edits never change a purchased shipment or its customs record.
  5. A draft or archived product used in a purchase returns 400 parameter_invalid (product_not_active).
Trade engine integration: POST /v1/classifications/{id}/apply accepts { "product": "prod_..." } as well as a shipment, writing into hs_codes and classification. Compliance screening caches its item results in restrictions[].

5. Events

product.created, product.updated (with previous_attributes), product.archived, product.unarchived, product.deleted, product.classification_needed (emitted when a shipment needs a destination code the product lacks).