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.Products
GET /products·POST /products·GET /products/{id}·PATCH /products/{id}·DELETE /products/{id}
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.
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": [] }
Duplicate
POST /products/{id}/duplicate→201with the full new product
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}
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.
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 }
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)
{ "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.
Import from a Shopify link
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.
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.
Catalog link (dropship bind)
GET /products/{id}/catalog-link·GET /products/{id}/catalog-link/enhancedPOST /products/{id}/catalog-link·DELETE /products/{id}/catalog-link
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.
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_idadditionally triggers a full template recompile. - Idempotency.
Idempotency-Keyis optional on this surface; when present, a replay with the same body returns the cached response and a different body returns409. Send it on every write anyway — see Idempotency. - Tenancy. The key’s merchant and store scope every query. Send
X-Store-IDwhen a key is bound to more than one store, or the request is rejected. - Errors.
400validation,403 FORBIDDEN_SCOPE,404unknown or deleted product,409idempotency conflict,429rate limit (200 req/min per IP on this group).