# Product setup

Use setup references and bounded product catalog writes safely.

Canonical HTML: https://developer.shelfcycle.com/guides/product-setup

Use setup references, dry-run readiness, and bounded product catalog writes to maintain safe setup records.

Product setup is intentionally narrow. The API can create and update product records, and it can create and update product families with public-safe identity and regulatory fields. Product updates that would change order-sensitive identity, packaging, or supplier semantics are blocked when the product already has sales order, purchase order, lot, price book, or supplier cost usage. Product setup does not create packaging, suppliers, prices, costs, inventory, binary uploads, document deletion, or accounting records. Use the Attached documents guide for parent-scoped product document links.

## Setup flow

1. Call `GET /me` and confirm the matching `effectiveCapabilities.products`, `effectiveCapabilities.productFamilies`, and `effectiveCapabilities.writeReadiness` flags.
2. Resolve product families with `GET /product-families` and active packaging with `GET /reference/product-packaging`.
3. If a product has a fixed supplier, verify the supplier is visible before including `supplierId`.
4. Submit the exact intended `POST` or `PATCH` request with `dryRun=true`.
5. Execute with the same body when your policy allows it. Use `Idempotency-Key` for `POST` and latest `updatedAt` as `If-Match` for `PATCH`.

## Endpoint map

| Endpoint | Scope | Use it for |
| --- | --- | --- |
| `GET /product-families` | `products:read`, `products:write`, or `search:read` | Find existing product family references by `q` or exact `externalId`. |
| `GET /product-families/{id}` | `products:read`, `products:write`, or `search:read` | Read one existing product family reference. |
| `GET /reference/product-packaging` | `products:read`, `products:write`, or `search:read` | List active packaging references by `q` or `unitOfMeasure`. |
| `POST /product-families` | `products:write` | Create one bounded product family from public-safe identity and regulatory fields. |
| `PATCH /product-families/{id}` | `products:write` | Update bounded product-family fields with optimistic concurrency and impact analysis. |
| `POST /products` | `products:write` | Create one bounded product catalog record after reference and duplicate checks. |
| `PATCH /products/{id}` | `products:write` | Update safe product identity, reference semantics, metadata, hazmat, freight, and pallet fields. |

Reference endpoints are lookup surfaces, not broad product hierarchy exports. Product-family lookup requires `q` or exact `externalId`; packaging lookup is capped and active-only. Product-family writes are bounded to name, external id, CAS, molecular formula, UN numbers, GHS signal word, and hazard symbols.

## Resolve references

```bash
curl "$SHELFCYCLE_API_BASE_URL/product-families?q=Acetone&limit=10" \
  -H "Authorization: Bearer $SHELFCYCLE_API_KEY"
```

```bash
curl "$SHELFCYCLE_API_BASE_URL/reference/product-packaging?q=Drum&limit=10" \
  -H "Authorization: Bearer $SHELFCYCLE_API_KEY"
```

Use the returned `productFamilyId` and `packagingId` in product create or update requests. Do not send display names in place of ids.

## Dry-run product create

```bash
curl "$SHELFCYCLE_API_BASE_URL/products?dryRun=true" \
  -X POST \
  -H "Authorization: Bearer $SHELFCYCLE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: intake:doc-913:create-product" \
  -d '{
    "code": "IPA-55-DRUM",
    "externalId": "intake-doc-913",
    "productFamilyId": "product-family-id",
    "packagingId": "packaging-id",
    "packagingType": "FIXED",
    "supplierType": "VARIABLE",
    "qty": "55",
    "casNumber": "67-63-0",
    "unNumber": "1219",
    "properShippingName": "Isopropanol",
    "hazardClass": ["3"],
    "packingGroup": "II",
    "hazardSymbols": ["flame"],
    "netUnitWeightLbs": "6.61",
    "packagesPerPallet": 4
  }'
```

```json
{
  "data": {
    "type": "write_readiness",
    "operation": "products.create",
    "status": "ready",
    "wouldWrite": false,
    "checks": [
      { "code": "scope_authorized", "status": "passed" },
      { "code": "code_attribute_boundary_clear", "status": "passed" },
      { "code": "product_family_reference_active", "status": "passed", "param": "productFamilyId" },
      { "code": "product_packaging_reference_active", "status": "passed", "param": "packagingId" },
      { "code": "duplicate_check_clear", "status": "passed" }
    ],
    "duplicateCandidates": [],
    "verification": {
      "available": true,
      "path": null,
      "pathTemplate": "/api/v1/products/{id}",
      "requiresScopes": ["products:read", "search:read"],
      "fallback": "detail_get"
    },
    "requestId": "request-id"
  }
}
```

`status: "blocked"` can still return HTTP 200. Inspect checks such as `product_family_reference_active`, `product_packaging_reference_active`, `supplier_reference_active`, `reference_visibility_blocked`, `variable_qty_valid`, `duplicate_candidate_found`, and `idempotency_key_required` before deciding the next action.

## Execute and replay

Remove `dryRun=true` to execute. Repeating the same route, same idempotency key, and same body returns the existing product with `idempotencyStatus: "replayed"`.

```json
{
  "data": {
    "type": "product",
    "id": "product-id",
    "code": "IPA-55-DRUM",
    "packagingType": "FIXED",
    "supplierType": "VARIABLE",
    "qty": "55",
    "productFamily": { "id": "product-family-id", "name": "Isopropyl Alcohol" },
    "packaging": { "id": "packaging-id", "name": "Drum", "unitOfMeasure": "GALLON" },
    "idempotencyStatus": "created"
  }
}
```

## Dry-run product update

Fetch the product first and send the latest `updatedAt` as `If-Match`. PATCH dry runs report the fields that would change and can include `impactAnalysis` when existing orders, lots, prices, or supplier costs constrain identity changes.

```bash
curl "$SHELFCYCLE_API_BASE_URL/products/product-id?dryRun=true" \
  -X PATCH \
  -H "Authorization: Bearer $SHELFCYCLE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "If-Match: 2026-06-30T14:35:00.000Z" \
  -d '{
    "externalId": "intake-doc-913-rev-b",
    "freightClass": "70",
    "hazardSymbols": ["flame", "health_hazard"]
  }'
```

```json
{
  "data": {
    "type": "write_readiness",
    "operation": "products.update",
    "status": "ready",
    "wouldWrite": false,
    "wouldChange": {
      "fieldNames": ["externalId", "freightClass", "hazardSymbols"]
    },
    "impactAnalysis": {
      "usageCounts": { "salesOrders": 1, "purchaseOrders": 1 },
      "blockedFieldNames": [],
      "allowedFieldNames": ["externalId", "freightClass", "hazardSymbols"],
      "orderEconomicsMutated": false
    },
    "requestId": "request-id"
  }
}
```

If the product is already used by sales orders, purchase orders, lots, price books, or supplier costs, changes to `code`, `productFamilyId`, `packagingId`, `packagingType`, `qty`, `supplierType`, and `supplierId` can return `blocked_order_sensitive_update`. Refresh the product before retrying any `stale_record` response.

## Product-family writes

Create product families with `POST /product-families` and update them with `PATCH /product-families/{id}`. Use `Idempotency-Key` for create and `If-Match` for update.

```bash
curl "$SHELFCYCLE_API_BASE_URL/product-families?dryRun=true" \
  -X POST \
  -H "Authorization: Bearer $SHELFCYCLE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: intake:doc-913:create-family" \
  -d '{
    "name": "Isopropyl Alcohol",
    "externalId": "family-ipa",
    "casNumber": "67-63-0",
    "molecularFormula": "C3H8O",
    "unNumbers": ["1219"],
    "ghsSignalWord": "Danger",
    "hazardSymbols": ["flame"]
  }'
```

Product-family PATCH can return `impactAnalysis.attachedProductCount` so clients know how many products are attached before changing shared family facts. It never rewrites product rows or creates products, suppliers, packaging, catalog attributes, document links, inventory, costs, prices, orders, or accounting records.

## Supplier and variable rules

Only include `supplierId` when `supplierType` is `FIXED`. The key must be able to verify supplier visibility through `suppliers:read` or `search:read`, and the supplier must be active in the same org.

For variable packaging, send `packagingType: "VARIABLE"` and `qty: "1"`. Variable packaging with any other quantity returns `invalid_variable_qty`, and execution stores quantity as 1.

## Unsupported fields

The product write contract rejects fields outside the setup boundary, including:

```text
displayName
name
productCodeAttributeValueIds
productCodeAttributeValues
sdsFile
documents
files
imageUrl
prices
costs
```

Use product family, packaging, supplier, and hazard/shipping facts that the OpenAPI schema accepts. Do not attempt to backfill fields outside the public product setup contract.

> **Guardrail**: Product setup stays bounded to product and product-family setup fields. It does not create packaging or suppliers, upload files, delete documents, set product code attributes or catalog attributes, create inventory, prices, costs, orders, lots, receiving, AP, AR, GL, or bypass duplicate checks.
