/api/v1/products domain API further down is the merchant dashboard’s own API.
Agent Gateway products API
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}
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: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-Keyrequired.PATCH /products/variants/{variant_id}updates one variant:price,compare_at_price,sku,title,inventory_quantity,inventory_tracked,inventory_policy.POST /products/variants/bulk_updateupdates 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
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}
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}·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’srequires_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-fileswith{ "filename", "content_type", "size_bytes", "variant_id"? }→{ "file", "upload_url", "upload_headers" }.PUTthe bytes toupload_urlwith those headers within 30 minutes (up to 2 GiB), thenPOST /products/{id}/digital-files/{file_id}/completechecks the upload and marks the fileready.POST /products/{id}/digital-files/from-urlwith{ "url", "filename"?, "variant_id"? }fetches a publichttps://file (up to 2 GiB) and returns thereadyfile in one call.PATCH /products/{id}/digital-files/{file_id}withfilename,variant_id(""= every variant) orposition.DELETE /products/{id}/digital-files/{file_id}stops new buyers getting the file. Buyers who already have it keep their 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}
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.
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).