Auth: X-Agent-Key: sq_agt_* (or a merchant user JWT). Scopes: read_products (reads), write_products (writes). Keys issued with the legacy catalog:read / catalog:write keep working — they are treated as equivalent. Money is always an integer number of cents (price: 2999 = $29.99). status is 0 draft · 1 active · 2 archived; responses also carry a status_label.
This is the products domain API on the /api/v1 base — not the /api/v1/agent/v1 gateway base that dtc_upsert_product and dtc_bulk_import_products use. Same key, same audit log, same Idempotency-Key semantics. The two will converge; until then prefer this surface, which is the one with variants, images and bulk operations.
Rolling out. Agent-key access to /api/v1/products is being enabled per merchant. Until your store is switched over, these routes accept the dashboard user JWT only and an X-Agent-Key is rejected. The request and response shapes below do not change at cutover.

Products

  • GET /products · POST /products · GET /products/{id} · PATCH /products/{id} · DELETE /products/{id}
List parameters: page (≥ 1, default 1), limit (1–100, default 20), search (matches title or handle, case-insensitive substring), status (0 · 1 · 2), sort_by (updated_at · created_at · title · price, default updated_at), sort_direction (asc · desc, default desc). Out-of-range values return 400.
The list envelope is page-based, not cursor-based:
GET /products/{id} returns the same object fully enriched — variants[], images[], description, category, sales_7_days, is_published. The list endpoint leaves description and category empty by design; fetch the product to get them. Create accepts title (required), handle, product_type, vendor, tags[], options[], price, status. It returns 201.
PATCH /products/{id} is a partial update — omitted fields are left untouched. It accepts title, handle, product_type, vendor, price, status, tags[], options[], supplier_id, pdp_template_id and fulfillment_rules[]. Send "" for supplier_id or pdp_template_id to unassign; omit them to leave them alone. description and category are accepted but ignored — they live in separate tables and are read-only here. fulfillment_rules is the destination-based routing table: an ordered list of { "country_codes": ["GB","IE"], "supplier_id": "sup_…" }, resolved at order finalize. Without a rule match the product falls back to its supplier_id.
DELETE /products/{id} is a soft delete — the row is retained and disappears from every read. There is no restore endpoint on this surface.

Facets

  • GET /products/facets{ "vendors": [], "product_types": [], "tags": [] }
Distinct non-empty vendors and product types plus the sorted union of every tag in the store. Use it to build filter values before listing.

Duplicate

  • POST /products/{id}/duplicate201 with the full new product
The copy carries the source’s variants, images, description and category. It is created as a new product row — catalog links and sales history are not copied.

Images

  • POST /products/{id}/images · DELETE /products/{id}/images/{image_id}
url is required; width and height are optional strings. Upload the file elsewhere and pass a URL — this endpoint stores a reference, it does not accept binary.

Variants

  • POST /products/{id}/variants · PATCH /products/variants/{variant_id} · DELETE /products/variants/{variant_id}
Create takes title (required) plus price, sku, option1, option2, option3, image_url, compare_at_price, inventory_quantity. Update takes title, sku, price, image_url, compare_at_price, inventory_quantity — all optional, all partial. Negative price, compare_at_price or inventory_quantity return 400.
Variant option names come from the product’s options[]; option1..3 are the values. Stock set here seeds the variant row — for multi-location stock use Inventory & Locations.
  • POST /products/variants/{variant_id}/sync-sku-to-orders{ "variant_sku": "…", "updated_count": 12 }
Copies the variant’s current SKU into the sku snapshot on every historical order item for that variant. order_item.sku is an immutable snapshot by design; this is the explicit repair action for orders written before a SKU was corrected.

Bulk operations

  • PATCH /products/bulk/status{ "product_ids": ["…"], "status": 1 }
  • PATCH /products/bulk/tags{ "product_ids": ["…"], "add_tags": ["sale"], "remove_tags": ["new"] }
  • DELETE /products/bulk{ "product_ids": ["…"] } (soft delete; ids travel in the body)
All three return { "affected_count": n }. product_ids must be non-empty, and bulk-tags needs at least one of add_tags / remove_tags, else 400. Tag edits are set semantics — affected_count counts products whose tag list actually changed, so re-running is a no-op.
POST /products/import/shopify turns public Shopify product URLs into products in this store — title, sanitized description, images, options, and every variant with its own price, compare-at price and SKU. POST /products/import/shopify/preview returns the same data without writing anything.
Up to 20 URLs per call. One bad link never aborts the batch — the response is 207 when some succeeded and some failed. Re-importing a link this store already holds returns it as skipped with the existing product_id rather than creating a duplicate. Images are re-hosted on your CDN rather than hotlinked. Money is copied verbatim in the source store’s currency — no conversion. Inventory is not imported: public storefronts expose whether a variant is in stock but never the count. Agent tools: dtc_import_shopify_product, dtc_preview_shopify_product. Full walkthrough in Import a product from a Shopify link.
  • GET /products/{id}/catalog-link · GET /products/{id}/catalog-link/enhanced
  • POST /products/{id}/catalog-link · DELETE /products/{id}/catalog-link
This is the “add to store” bind between a merchant product’s variants and sourcing-catalog variants. POST requires spu_id or a non-empty catalog_variants[] (catalog_variant_id, catalog_product_id, quantity 1–100, max 200 items). variant_relation[] maps your product_variant_id to a catalog_variant_id (max 200). is_additive (also accepted as isAdditive) keeps existing links instead of replacing them.
Reads return product_id, catalog_product_id, bind_status, linked_variant_count, total_variant_count and variant_links[]. bind_status is 1 unbound · 2 partially bound · 3 fully bound. DELETE clears every link and resets the product to bind_status: 1, returning { "product_id": "…", "bind_status": 1, "cleared_variant_count": n }. The /enhanced read adds the resolved catalog product and variant detail for each link.

Behaviour worth knowing

  • Storefront resync. Every write except catalog link/unlink schedules a storefront resync, so published stores pick the change up without a manual republish. Changing pdp_template_id additionally triggers a full template recompile.
  • Idempotency. Idempotency-Key is optional on this surface; when present, a replay with the same body returns the cached response and a different body returns 409. Send it on every write anyway — see Idempotency.
  • Tenancy. The key’s merchant and store scope every query. Send X-Store-ID when a key is bound to more than one store, or the request is rejected.
  • Errors. 400 validation, 403 FORBIDDEN_SCOPE, 404 unknown or deleted product, 409 idempotency conflict, 429 rate limit (200 req/min per IP on this group).
Filtering by vendor, tag, product_type or a date range is available on the merchant dashboard API but not on this agent surface yet — GET /products here supports only the parameters listed above. Fetch with search/status and filter client-side, or use GET /products/facets to discover values.