There are two product surfaces. Apps and agents should use the Agent Gateway (first section below). The /api/v1/products domain API further down is the merchant dashboard’s own API.
Selling ebooks, templates or other files? See Sell digital products for the merchant guide, and Digital files below for the API.

Agent Gateway products API

Auth: Authorization: Bearer sq_agt_* (or X-Agent-Key). Scopes: read_products for reads, write_products for writes; the legacy catalog:read / catalog:write names are equivalent.

Money

Every money field is a decimal string in the store currency’s major units, always with two decimals, the same as Shopify’s REST Admin API: "price": "19.99". Each product carries currency (ISO 4217, for example "USD"). Absent amounts are null, for example a variant with no sale has "compare_at_price": null. Inputs accept a decimal string (preferred) or a JSON number, in major units. More than two decimals, or a negative amount, returns 422.

List products

  • GET /products · GET /products/count · GET /products/{id}
One request returns up to 250 products. Add include=variants and each product carries its variants (id, title, SKU, price, compare-at price, options, stock), so reading a whole catalog’s prices costs one request per 250 products. Unknown parameters return 400, so a typo never silently returns the whole catalog.

Pagination

Follow the cursor:
Pass page_info back with limit (and optionally fields / include). The filters and sort order you started with travel inside the cursor; sending another filter together with page_info returns 400. Cursors are opaque: never build or edit one. meta.total is the number of products matching the filters. GET /products/count returns only that number. For an incremental sync, list with order=updated_at asc&updated_at_min=<last sync>: an edit made while you page moves the product to the end of the scan instead of past it.

Update products and variants

  • PUT /products/{id} updates title, handle, product type, vendor, tags (replaces the list), status (DRAFT · ACTIVE · ARCHIVED), description, category. Idempotency-Key required.
  • PATCH /products/variants/{variant_id} updates one variant: price, compare_at_price, sku, title, inventory_quantity, inventory_tracked, inventory_policy.
  • POST /products/variants/bulk_update updates up to 250 variants in one call. See Bulk edit prices.
compare_at_price must be higher than price, or null to end a sale, otherwise 422. Variant writes fire the products/update webhook and refresh the storefront.

Dashboard products API

Auth: a merchant user JWT. Scopes: read_products (reads), write_products (writes). 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.
Apps: use the Agent Gateway products API above. It is the surface that accepts app install tokens for listing and reading products, returns variants in the list, and supports filters and cursor pagination.

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}/duplicate → 201 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} · PUT /products/{id}/images/order
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. Images keep the order they were added in. To reorder, PUT /products/{id}/images/order with {"image_ids": [...]} listing every image of the product exactly once, in the new order (a partial or duplicated list returns 422). The first image becomes the featured image — the storefront cover and product lists follow it.

Digital files

A variant’s requires_shipping (boolean, default true) says whether it ships: false is a download (or a service) with no shipping address at checkout. It is read and written everywhere a variant is: GET /products/{id}, POST /products, POST /products/{id}/variants, PATCH /products/variants/{variant_id}, bulk updates, duplicate and the CSV / Shopify imports. Two product fields set the buyer’s download rules: download_limit (downloads per file per link, 1–100, default 5) and download_days (how long the link works, 1–3650, default 30). GET /products/{id} also returns digital_files[]:
variant_id: null means every variant of the product gets the file. Only ready files are delivered.
  • POST /products/{id}/digital-files with { "filename", "content_type", "size_bytes", "variant_id"? } → { "file", "upload_url", "upload_headers" }. PUT the bytes to upload_url with those headers within 30 minutes (up to 2 GiB), then
  • POST /products/{id}/digital-files/{file_id}/complete checks the upload and marks the file ready.
  • POST /products/{id}/digital-files/from-url with { "url", "filename"?, "variant_id"? } fetches a public https:// file (up to 2 GiB) and returns the ready file in one call.
  • PATCH /products/{id}/digital-files/{file_id} with filename, variant_id ("" = every variant) or position.
  • DELETE /products/{id}/digital-files/{file_id} stops new buyers getting the file. Buyers who already have it keep their downloads.
An order’s downloads: GET /orders/{id}/downloads returns status (active · held · revoked, or null when the order has no downloads), status_reason, expires_at, sent_at, email, link and files[] with download_count, download_limit, total_downloads and last_download_at. POST /orders/{id}/downloads/release sends a held order’s downloads, POST /orders/{id}/downloads/resend emails a fresh link (new expiry, counts start again), and POST /orders/{id}/downloads/revoke with an optional reason turns them off.

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. compare_at_price is the “was” price a sale is struck through from. It must be higher than price, or null (not on sale); anything else returns 422 “Compare-at price needs to be higher than price”. Send "compare_at_price": null to end a sale. The rule is checked whenever a write touches price or compare_at_price.
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).