Create, update, and manage products, variants, images, and digital files
/admin/api/products
List products with pagination, filtering, sorting, and search.
/admin/api/products
Create a new product. A URL slug is auto-derived from the name. Preview-tier shops are capped at 100 products — exceeding the cap returns 402 with `error: tier_limit_exceeded`, `limit: max_products`. See the Tenant Tier reference.
/admin/api/products/{id}
Fetch a single product by its numeric ID. Includes content translations.
/admin/api/products/{id}
Replace all editable fields of an existing product. Re-derives the slug if the name changes.
/admin/api/products/{id}
Soft-delete a product. The row is kept to preserve order history.
/admin/api/products/export.csv
Stream a CSV of products and variants matching the current list filters. The editable columns are `price`, `compare_at_price`, and `stock`; everything else is read-only context. `category` is always included; per-option columns are appended when `include_options=true` so the spreadsheet stays searchable by attribute (e.g. Brand=Apple).
/admin/api/products/import/preview
Upload a CSV (multipart, single `file` field) and get a parsed preview of the changes that would be applied. Nothing is written. Re-uploading the same file to `/import/confirm` performs the writes. The CSV must include the required columns: kind, id, product_id, product_name, variant_label, sku, price, compare_at_price, stock, updated_at. Only `price`, `compare_at_price`, and `stock` are editable; other columns are matched for identity or treated as read-only context. Auto-detects `,`, `;`, or tab delimiters. Max 20 MB and 100,000 rows.
/admin/api/products/import/confirm
Re-upload the same CSV that was sent to `/import/preview` to apply the changes. The server re-parses, re-validates, and writes all updates in a single transaction so DB drift between preview and confirm is caught. By default any row-level error returns 422 with the full error list; pass `skip_invalid_rows=true` to drop invalid rows and apply the rest. File-level errors (parse, missing column, oversized) always return 422.
/admin/api/products/{id}/variants
Get the options and variants for a product. Options are tenant-level; variants are product-level combinations.
/admin/api/products/{id}/variants
Replace all options and variants for a product. Each variant must specify a value for every option.
/admin/api/products/{id}/attributes
Get the attribute values currently assigned to a product. Returns option/value pairs grouped by attribute.
/admin/api/products/{id}/attributes
Replace all attribute values for a product. Each referenced option must have option_type 'attribute'.
/admin/api/products/{id}/images
List all images for a product, ordered by sort_order.
/admin/api/products/{id}/images
Upload a product image via multipart form. The image is resized to max 1200x1200px and re-encoded as WebP.
/admin/api/products/{id}/images/reorder
Reorder images for a product. All image IDs must belong to the product.
/admin/api/products/{id}/images/{img_id}
Delete a product image. Removes the record and the file from object storage.
/admin/api/files
List every digital file in the tenant's library.
/admin/api/files
Upload a new digital file via multipart form (single `file` field). Files are stored as-is (no processing) and added to the tenant's flat file library. To make a file available to customers, attach it to one or more access groups via PUT /admin/api/access-groups/{id}/files. Tier caps are enforced — exceeding the per-file or aggregate digital-bytes cap returns 402 with `error: tier_limit_exceeded`. The request body limit is 128 MiB.
/admin/api/files/{file_id}
Delete a digital file from the library. Removes the record and the underlying object from storage. Cascades to remove the file from any access groups it was attached to.
/admin/api/products/{id}/recommendations
List all cross-sell and upsell recommendation links for a product.
/admin/api/products/{id}/recommendations
Replace all recommendations of a given type for a product. Pass an empty target list to clear that type.
/admin/api/products/{id}/recommendations/{rec_id}
Delete a single recommendation link by its ID.
/admin/api/products/{id}/bundle-items
List all bundle components for a bundle product.
/admin/api/products/{id}/bundle-items
Replace all bundle components for a product. Pass an empty items list to clear the bundle.
/admin/api/products/{id}/custom-fields
Get the custom fields currently linked to a product.
/admin/api/products/{id}/custom-fields
Replace the set of custom fields linked to a product. Pass custom field IDs in display order.
/admin/api/products/{id}/categories
Get the category IDs currently assigned to a product.
/admin/api/products/{id}/categories
Replace all category assignments for a product. Pass an empty list to clear all assignments.
/admin/api/products/bulk
Apply the same set of boolean field overrides to many products in a single round trip. Pass the product `ids` (1–500) and a `changes` object; only the `changes` fields you set are written, the rest are left untouched. At least one `changes` field must be present. Soft-deleted rows in `ids` are silently ignored. Returns the count of distinct live products that matched.
/admin/api/products/{id}/clone
Deep-clone an existing product, including its images (each image is server-side-copied to a fresh object-storage key under the new product so deletes on either side stay isolated). The clone is hidden by default. Counts against the tenant product cap — exceeding it returns 402 with `error: tier_limit_exceeded`, `limit: max_products`. Returns the new product's id and slug.
/admin/api/products/create-import/example.csv
Download a filled-in example CSV template that demonstrates all 39 supported columns (Handle, Name, Description, Product Type, Price, variants, images, categories, …) with two example products. Use it as the starting point for a create-import.
/admin/api/products/create-import/preview
Upload a create-import CSV (multipart, single `file` field) and get a validation + planning preview. Nothing is written. Runs row-level, per-handle, and cross-row validation, plans the inserts against current DB state, and HEAD-checks every image URL (only when no other errors exist). Returns aggregate counts plus the full error/warning lists. `can_confirm` is true only when there are no errors. Max 50 MB and 10,000 rows.
/admin/api/products/create-import/confirm
Re-upload the same CSV sent to `/create-import/preview` to apply it. The server re-parses and re-validates (the flow is stateless), then inserts all products, variants, categories, and options and fetches images in one pass. On validation failure it returns 422 with the same preview-shaped body (errors populated, created empty) so the UI can re-render. Image fetch failures are non-fatal and reported in `failed_images`. Applying the plan counts against the tenant product cap — exceeding it returns 402 with `error: tier_limit_exceeded`, `limit: max_products`. Max 50 MB and 10,000 rows.
/admin/api/products
List products with pagination, filtering, sorting, and search.
| Name | Type | Required | Description |
|---|---|---|---|
| page | integer | optional | Page number, 1-indexed (default: 1) |
| per_page | integer | optional | Items per page, 1-100 (default: 20) |
| search | string | optional | Case-insensitive text search on product name |
| visible | string | optional | Filter by visibility: "true" or "false" |
| product_type | string | optional | Filter by type: "physical" or "digital" |
| price_min | string | optional | Minimum price as decimal string (e.g. "10.00") |
| price_max | string | optional | Maximum price as decimal string (e.g. "50.00") |
| stock_min | integer | optional | Minimum stock level (inclusive) |
| stock_max | integer | optional | Maximum stock level (inclusive) |
| created_from | string | optional | Earliest creation date (inclusive), as YYYY-MM-DD |
| created_to | string | optional | Latest creation date (inclusive), as YYYY-MM-DD |
| sort | string | optional | Sort field with optional - prefix for descending. Valid: name, price, stock, created_at. Default: -created_at |
{
"data": [
{
"id": 1,
"slug": "classic-cotton-tee",
"name": "Classic Cotton Tee",
"price": "29.99",
"stock": 150,
"visible": true,
"created_at": "2025-11-20T14:30:00Z",
"product_type": "physical",
"track_stock": true
},
{
"id": 2,
"slug": "digital-photography-guide",
"name": "Digital Photography Guide",
"price": "14.99",
"stock": 0,
"visible": true,
"created_at": "2025-11-19T09:15:00Z",
"product_type": "digital",
"track_stock": false
}
],
"page": 1,
"per_page": 20,
"total": 42,
"total_pages": 3
}
curl "https://yourshop.zeroshop.io/admin/api/products?page=1&per_page=20&search=tee&sort=-price" \
-H "Authorization: Bearer zspat_..."
import requests
resp = requests.get(
"https://yourshop.zeroshop.io/admin/api/products",
params={"page": 1, "per_page": 20, "search": "tee", "sort": "-price"},
headers={"Authorization": "Bearer zspat_..."}
)
products = resp.json()
const params = new URLSearchParams({ page: 1, per_page: 20, search: "tee", sort: "-price" });
const resp = await fetch(
`https://yourshop.zeroshop.io/admin/api/products?${params}`,
{ headers: { "Authorization": "Bearer zspat_..." } }
);
const data = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/products?page=1&per_page=20&search=tee")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer zspat_..."
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
data = JSON.parse(resp.body)
/admin/api/products
Create a new product. A URL slug is auto-derived from the name. Preview-tier shops are capped at 100 products — exceeding the cap returns 402 with `error: tier_limit_exceeded`, `limit: max_products`. See the Tenant Tier reference.
{
"name": "Classic Cotton Tee",
"description": "A comfortable 100% cotton t-shirt available in multiple sizes.",
"price": "29.99",
"compare_at_price": "39.99",
"sku": "CCT-001",
"stock": 150,
"visible": true,
"tax_class_id": null,
"product_type": "physical",
"track_stock": true,
"download_limit": null,
"download_expiry_days": null,
"is_subscription": false,
"billing_interval": null,
"billing_interval_count": null,
"cancellation_policy": null,
"max_billing_cycles": null,
"meta_title": "Classic Cotton Tee — Soft Everyday Basic",
"meta_description": "A comfortable 100% cotton t-shirt available in multiple sizes.",
"og_image_media_id": null,
"translations": [
{
"locale": "de",
"field": "name",
"value": "Klassisches Baumwoll-T-Shirt"
},
{
"locale": "de",
"field": "description",
"value": "Ein bequemes T-Shirt aus 100% Baumwolle."
}
]
}
{
"id": 1,
"slug": "classic-cotton-tee",
"name": "Classic Cotton Tee",
"description": "A comfortable 100% cotton t-shirt available in multiple sizes.",
"price": "29.99",
"compare_at_price": "39.99",
"sku": "CCT-001",
"stock": 150,
"visible": true,
"created_at": "2025-11-20T14:30:00Z",
"updated_at": "2025-11-20T14:30:00Z",
"has_variants": false,
"tax_class_id": null,
"product_type": "physical",
"track_stock": true,
"download_limit": null,
"download_expiry_days": null,
"is_subscription": false,
"billing_interval": null,
"billing_interval_count": null,
"cancellation_policy": null,
"max_billing_cycles": null,
"translations": {
"de": {
"name": "Klassisches Baumwoll-T-Shirt",
"description": "Ein bequemes T-Shirt aus 100% Baumwolle."
}
}
}
curl -X POST "https://yourshop.zeroshop.io/admin/api/products" \
-H "Authorization: Bearer zspat_..." \
-H "Content-Type: application/json" \
-d '{
"name": "Classic Cotton Tee",
"description": "A comfortable 100% cotton t-shirt.",
"price": "29.99",
"compare_at_price": "39.99",
"sku": "CCT-001",
"stock": 150,
"visible": true,
"product_type": "physical"
}'
import requests
resp = requests.post(
"https://yourshop.zeroshop.io/admin/api/products",
headers={"Authorization": "Bearer zspat_..."},
json={
"name": "Classic Cotton Tee",
"description": "A comfortable 100% cotton t-shirt.",
"price": "29.99",
"compare_at_price": "39.99",
"sku": "CCT-001",
"stock": 150,
"visible": True,
"product_type": "physical"
}
)
product = resp.json()
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/products", {
method: "POST",
headers: {
"Authorization": "Bearer zspat_...",
"Content-Type": "application/json"
},
body: JSON.stringify({
name: "Classic Cotton Tee",
description: "A comfortable 100% cotton t-shirt.",
price: "29.99",
compare_at_price: "39.99",
sku: "CCT-001",
stock: 150,
visible: true,
product_type: "physical"
})
});
const product = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/products")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "application/json"
req.body = {
name: "Classic Cotton Tee",
description: "A comfortable 100% cotton t-shirt.",
price: "29.99",
stock: 150,
visible: true,
product_type: "physical"
}.to_json
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
product = JSON.parse(resp.body)
/admin/api/products/{id}
Fetch a single product by its numeric ID. Includes content translations.
{
"id": 1,
"slug": "classic-cotton-tee",
"name": "Classic Cotton Tee",
"description": "A comfortable 100% cotton t-shirt available in multiple sizes.",
"price": "29.99",
"compare_at_price": "39.99",
"sku": "CCT-001",
"stock": 150,
"visible": true,
"created_at": "2025-11-20T14:30:00Z",
"updated_at": "2025-11-22T10:05:00Z",
"has_variants": true,
"tax_class_id": null,
"product_type": "physical",
"track_stock": true,
"download_limit": null,
"download_expiry_days": null,
"is_subscription": false,
"billing_interval": null,
"billing_interval_count": null,
"cancellation_policy": null,
"max_billing_cycles": null,
"translations": {
"de": {
"name": "Klassisches Baumwoll-T-Shirt",
"description": "Ein bequemes T-Shirt aus 100% Baumwolle."
}
}
}
curl "https://yourshop.zeroshop.io/admin/api/products/1" \
-H "Authorization: Bearer zspat_..."
import requests
resp = requests.get(
"https://yourshop.zeroshop.io/admin/api/products/1",
headers={"Authorization": "Bearer zspat_..."}
)
product = resp.json()
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/products/1", {
headers: { "Authorization": "Bearer zspat_..." }
});
const product = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/1")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer zspat_..."
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
product = JSON.parse(resp.body)
/admin/api/products/{id}
Replace all editable fields of an existing product. Re-derives the slug if the name changes.
{
"name": "Classic Cotton Tee",
"description": "Updated description for the classic cotton tee.",
"price": "24.99",
"compare_at_price": "39.99",
"sku": "CCT-001",
"stock": 200,
"visible": true,
"tax_class_id": null,
"product_type": "physical",
"track_stock": true,
"download_limit": null,
"download_expiry_days": null,
"is_subscription": false,
"billing_interval": null,
"billing_interval_count": null,
"cancellation_policy": null,
"max_billing_cycles": null,
"meta_title": "Classic Cotton Tee — Soft Everyday Basic",
"meta_description": "Updated description for the classic cotton tee.",
"og_image_media_id": null,
"quick_order_enabled": true,
"translations": [
{
"locale": "de",
"field": "name",
"value": "Klassisches Baumwoll-T-Shirt"
}
]
}
curl -X PUT "https://yourshop.zeroshop.io/admin/api/products/1" \
-H "Authorization: Bearer zspat_..." \
-H "Content-Type: application/json" \
-d '{
"name": "Classic Cotton Tee",
"description": "Updated description.",
"price": "24.99",
"compare_at_price": "39.99",
"sku": "CCT-001",
"stock": 200,
"visible": true,
"product_type": "physical",
"track_stock": true
}'
import requests
resp = requests.put(
"https://yourshop.zeroshop.io/admin/api/products/1",
headers={"Authorization": "Bearer zspat_..."},
json={
"name": "Classic Cotton Tee",
"description": "Updated description.",
"price": "24.99",
"stock": 200,
"visible": True,
"product_type": "physical",
"track_stock": True
}
)
# 204 No Content on success
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/products/1", {
method: "PUT",
headers: {
"Authorization": "Bearer zspat_...",
"Content-Type": "application/json"
},
body: JSON.stringify({
name: "Classic Cotton Tee",
description: "Updated description.",
price: "24.99",
stock: 200,
visible: true,
product_type: "physical",
track_stock: true
})
});
// 204 No Content on success
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/1")
req = Net::HTTP::Put.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "application/json"
req.body = {
name: "Classic Cotton Tee",
description: "Updated description.",
price: "24.99",
stock: 200,
visible: true,
product_type: "physical",
track_stock: true
}.to_json
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
/admin/api/products/{id}
Soft-delete a product. The row is kept to preserve order history.
curl -X DELETE "https://yourshop.zeroshop.io/admin/api/products/1" \
-H "Authorization: Bearer zspat_..."
import requests
resp = requests.delete(
"https://yourshop.zeroshop.io/admin/api/products/1",
headers={"Authorization": "Bearer zspat_..."}
)
# 204 No Content on success
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/products/1", {
method: "DELETE",
headers: { "Authorization": "Bearer zspat_..." }
});
// 204 No Content on success
require "net/http"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/1")
req = Net::HTTP::Delete.new(uri)
req["Authorization"] = "Bearer zspat_..."
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
/admin/api/products/export.csv
Stream a CSV of products and variants matching the current list filters. The editable columns are `price`, `compare_at_price`, and `stock`; everything else is read-only context. `category` is always included; per-option columns are appended when `include_options=true` so the spreadsheet stays searchable by attribute (e.g. Brand=Apple).
| Name | Type | Required | Description |
|---|---|---|---|
| search | string | optional | Case-insensitive text search on product name |
| visible | string | optional | Filter by visibility: "true" or "false" |
| product_type | string | optional | Filter by type: "physical" or "digital" |
| price_min | string | optional | Minimum price as decimal string (e.g. "10.00") |
| price_max | string | optional | Maximum price as decimal string (e.g. "50.00") |
| stock_min | integer | optional | Minimum stock level (inclusive) |
| stock_max | integer | optional | Maximum stock level (inclusive) |
| created_from | string | optional | Earliest creation date (inclusive), as YYYY-MM-DD |
| created_to | string | optional | Latest creation date (inclusive), as YYYY-MM-DD |
| category_id | integer | optional | Restrict to products assigned to this category id |
| option_value_id | integer | optional | Restrict to products with this option value (variants or attributes) |
| include_options | boolean | optional | When true, append one column per option/attribute name (alphabetical). Default false. |
| sort | string | optional | Sort field with optional - prefix for descending. Valid: name, price, stock, created_at. Default: -created_at |
"kind,id,product_id,product_name,variant_label,sku,price,compare_at_price,stock,updated_at,category\nproduct,1,1,Classic Cotton Tee,,TEE-001,29.99,34.99,150,2025-11-20T14:30:00Z,Apparel\nvariant,7,2,Merino Wool Beanie,Charcoal / M,BEANIE-CHR-M,24.99,,42,2025-11-21T08:15:00Z,Accessories\nvariant,8,2,Merino Wool Beanie,Charcoal / L,BEANIE-CHR-L,24.99,,38,2025-11-21T08:15:00Z,Accessories\n"
curl "https://yourshop.zeroshop.io/admin/api/products/export.csv?include_options=true" \
-H "Authorization: Bearer zspat_..." \
-o products-export.csv
import requests
resp = requests.get(
"https://yourshop.zeroshop.io/admin/api/products/export.csv",
params={"include_options": "true"},
headers={"Authorization": "Bearer zspat_..."},
)
with open("products-export.csv", "wb") as f:
f.write(resp.content)
const resp = await fetch(
"https://yourshop.zeroshop.io/admin/api/products/export.csv?include_options=true",
{ headers: { "Authorization": "Bearer zspat_..." } }
);
const blob = await resp.blob();
// e.g. trigger a download in the browser
const url = URL.createObjectURL(blob);
require "net/http"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/export.csv?include_options=true")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer zspat_..."
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
File.binwrite("products-export.csv", resp.body)
/admin/api/products/import/preview
Upload a CSV (multipart, single `file` field) and get a parsed preview of the changes that would be applied. Nothing is written. Re-uploading the same file to `/import/confirm` performs the writes. The CSV must include the required columns: kind, id, product_id, product_name, variant_label, sku, price, compare_at_price, stock, updated_at. Only `price`, `compare_at_price`, and `stock` are editable; other columns are matched for identity or treated as read-only context. Auto-detects `,`, `;`, or tab delimiters. Max 20 MB and 100,000 rows.
{
"file": "(CSV file with the header described above; up to 20 MB)"
}
{
"summary": {
"total_rows": 3,
"changed": 2,
"unchanged": 1,
"warnings": 1,
"errors": 0,
"skippable_error_rows": 0,
"valid_change_rows": 2
},
"changes": [
{
"row": 2,
"kind": "product",
"id": 1,
"product_name": "Classic Cotton Tee",
"variant_label": "",
"fields": {
"price": {
"old": "29.99",
"new": "27.99"
},
"compare_at_price": null,
"stock": {
"old": 150,
"new": 180
}
},
"warnings": []
},
{
"row": 3,
"kind": "variant",
"id": 7,
"product_name": "Merino Wool Beanie",
"variant_label": "Charcoal / M",
"fields": {
"price": null,
"compare_at_price": null,
"stock": {
"old": 42,
"new": 30
}
},
"warnings": [
"product_name in CSV differs from DB; ignored"
]
}
],
"errors": [],
"stale_rows": [],
"can_confirm": true
}
curl -X POST "https://yourshop.zeroshop.io/admin/api/products/import/preview" \
-H "Authorization: Bearer zspat_..." \
-F "file=@products-edited.csv"
import requests
with open("products-edited.csv", "rb") as f:
resp = requests.post(
"https://yourshop.zeroshop.io/admin/api/products/import/preview",
headers={"Authorization": "Bearer zspat_..."},
files={"file": ("products-edited.csv", f, "text/csv")},
)
preview = resp.json()
print(preview["summary"], "can_confirm:", preview["can_confirm"])
const formData = new FormData();
formData.append("file", fileInput.files[0]);
const resp = await fetch(
"https://yourshop.zeroshop.io/admin/api/products/import/preview",
{
method: "POST",
headers: { "Authorization": "Bearer zspat_..." },
body: formData,
}
);
const preview = await resp.json();
require "net/http"
require "json"
require "securerandom"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/import/preview")
boundary = "----FormBoundary#{SecureRandom.hex(8)}"
csv_data = File.binread("products-edited.csv")
body = "--#{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"products-edited.csv\"\r\nContent-Type: text/csv\r\n\r\n#{csv_data}\r\n--#{boundary}--\r\n"
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
req.body = body
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
preview = JSON.parse(resp.body)
/admin/api/products/import/confirm
Re-upload the same CSV that was sent to `/import/preview` to apply the changes. The server re-parses, re-validates, and writes all updates in a single transaction so DB drift between preview and confirm is caught. By default any row-level error returns 422 with the full error list; pass `skip_invalid_rows=true` to drop invalid rows and apply the rest. File-level errors (parse, missing column, oversized) always return 422.
| Name | Type | Required | Description |
|---|---|---|---|
| skip_invalid_rows | boolean | optional | If true, drop rows with row-level errors and apply the rest. Default false (any error blocks the import). |
{
"file": "(same CSV uploaded to /import/preview; up to 20 MB)"
}
{
"applied": 2,
"products_touched": 1,
"variants_touched": 1,
"skipped": 0,
"skipped_rows": []
}
curl -X POST "https://yourshop.zeroshop.io/admin/api/products/import/confirm" \
-H "Authorization: Bearer zspat_..." \
-F "file=@products-edited.csv"
import requests
with open("products-edited.csv", "rb") as f:
resp = requests.post(
"https://yourshop.zeroshop.io/admin/api/products/import/confirm",
params={"skip_invalid_rows": "false"},
headers={"Authorization": "Bearer zspat_..."},
files={"file": ("products-edited.csv", f, "text/csv")},
)
result = resp.json()
print(f"Applied {result['applied']} rows ({result['products_touched']} products, {result['variants_touched']} variants)")
const formData = new FormData();
formData.append("file", fileInput.files[0]);
const resp = await fetch(
"https://yourshop.zeroshop.io/admin/api/products/import/confirm",
{
method: "POST",
headers: { "Authorization": "Bearer zspat_..." },
body: formData,
}
);
const result = await resp.json();
require "net/http"
require "json"
require "securerandom"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/import/confirm")
boundary = "----FormBoundary#{SecureRandom.hex(8)}"
csv_data = File.binread("products-edited.csv")
body = "--#{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"products-edited.csv\"\r\nContent-Type: text/csv\r\n\r\n#{csv_data}\r\n--#{boundary}--\r\n"
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
req.body = body
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
result = JSON.parse(resp.body)
/admin/api/products/{id}/variants
Get the options and variants for a product. Options are tenant-level; variants are product-level combinations.
{
"options": [
{
"id": 1,
"name": "Size",
"values": [
{
"id": 10,
"value": "S"
},
{
"id": 11,
"value": "M"
},
{
"id": 12,
"value": "L"
}
]
},
{
"id": 2,
"name": "Color",
"values": [
{
"id": 20,
"value": "Red"
},
{
"id": 21,
"value": "Blue"
}
]
}
],
"variants": [
{
"id": 100,
"slug": "classic-cotton-tee-s-red",
"sku": "CCT-S-RED",
"price": "29.99",
"compare_at_price": "39.99",
"stock": 25,
"visible": true,
"option_value_ids": [
10,
20
],
"label": "S / Red",
"weight_grams": 200,
"length_cm": 30.0,
"width_cm": 25.0,
"height_cm": 2.0
},
{
"id": 101,
"slug": "classic-cotton-tee-m-blue",
"sku": "CCT-M-BLU",
"price": "29.99",
"compare_at_price": null,
"stock": 40,
"visible": true,
"option_value_ids": [
11,
21
],
"label": "M / Blue",
"weight_grams": 220,
"length_cm": 32.0,
"width_cm": 26.0,
"height_cm": 2.0
}
]
}
curl "https://yourshop.zeroshop.io/admin/api/products/1/variants" \
-H "Authorization: Bearer zspat_..."
import requests
resp = requests.get(
"https://yourshop.zeroshop.io/admin/api/products/1/variants",
headers={"Authorization": "Bearer zspat_..."}
)
data = resp.json()
print(f"Options: {len(data['options'])}, Variants: {len(data['variants'])}")
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/products/1/variants", {
headers: { "Authorization": "Bearer zspat_..." }
});
const { options, variants } = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/1/variants")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer zspat_..."
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
data = JSON.parse(resp.body)
/admin/api/products/{id}/variants
Replace all options and variants for a product. Each variant must specify a value for every option.
{
"option_ids": [
1,
2
],
"variants": [
{
"slug": "classic-cotton-tee-s-red",
"sku": "CCT-S-RED",
"price": "29.99",
"compare_at_price": "39.99",
"stock": 25,
"visible": true,
"option_value_ids": [
10,
20
],
"weight_grams": 200,
"length_cm": 30.0,
"width_cm": 25.0,
"height_cm": 2.0
},
{
"slug": "classic-cotton-tee-m-blue",
"sku": "CCT-M-BLU",
"price": "29.99",
"compare_at_price": null,
"stock": 40,
"visible": true,
"option_value_ids": [
11,
21
],
"weight_grams": 220,
"length_cm": 32.0,
"width_cm": 26.0,
"height_cm": 2.0
}
]
}
curl -X PUT "https://yourshop.zeroshop.io/admin/api/products/1/variants" \
-H "Authorization: Bearer zspat_..." \
-H "Content-Type: application/json" \
-d '{
"option_ids": [1, 2],
"variants": [
{
"sku": "CCT-S-RED",
"price": "29.99",
"stock": 25,
"visible": true,
"option_value_ids": [10, 20],
"weight_grams": 200
}
]
}'
import requests
resp = requests.put(
"https://yourshop.zeroshop.io/admin/api/products/1/variants",
headers={"Authorization": "Bearer zspat_..."},
json={
"option_ids": [1, 2],
"variants": [
{
"sku": "CCT-S-RED",
"price": "29.99",
"stock": 25,
"visible": True,
"option_value_ids": [10, 20],
"weight_grams": 200
}
]
}
)
# 204 No Content on success
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/products/1/variants", {
method: "PUT",
headers: {
"Authorization": "Bearer zspat_...",
"Content-Type": "application/json"
},
body: JSON.stringify({
option_ids: [1, 2],
variants: [
{
sku: "CCT-S-RED",
price: "29.99",
stock: 25,
visible: true,
option_value_ids: [10, 20],
weight_grams: 200
}
]
})
});
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/1/variants")
req = Net::HTTP::Put.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "application/json"
req.body = {
option_ids: [1, 2],
variants: [
{ sku: "CCT-S-RED", price: "29.99", stock: 25, visible: true, option_value_ids: [10, 20] }
]
}.to_json
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
/admin/api/products/{id}/attributes
Get the attribute values currently assigned to a product. Returns option/value pairs grouped by attribute.
[
{
"option_id": 5,
"option_name": "Material",
"values": [
{
"id": 50,
"value": "Cotton"
},
{
"id": 51,
"value": "Polyester"
}
]
},
{
"option_id": 6,
"option_name": "Season",
"values": [
{
"id": 60,
"value": "Summer"
}
]
}
]
curl "https://yourshop.zeroshop.io/admin/api/products/1/attributes" \
-H "Authorization: Bearer zspat_..."
import requests
resp = requests.get(
"https://yourshop.zeroshop.io/admin/api/products/1/attributes",
headers={"Authorization": "Bearer zspat_..."}
)
attributes = resp.json()
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/products/1/attributes", {
headers: { "Authorization": "Bearer zspat_..." }
});
const attributes = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/1/attributes")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer zspat_..."
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
attributes = JSON.parse(resp.body)
/admin/api/products/{id}/attributes
Replace all attribute values for a product. Each referenced option must have option_type 'attribute'.
{
"attributes": [
{
"option_id": 5,
"option_value_ids": [
50,
51
]
},
{
"option_id": 6,
"option_value_ids": [
60
]
}
]
}
curl -X PUT "https://yourshop.zeroshop.io/admin/api/products/1/attributes" \
-H "Authorization: Bearer zspat_..." \
-H "Content-Type: application/json" \
-d '{
"attributes": [
{ "option_id": 5, "option_value_ids": [50, 51] },
{ "option_id": 6, "option_value_ids": [60] }
]
}'
import requests
resp = requests.put(
"https://yourshop.zeroshop.io/admin/api/products/1/attributes",
headers={"Authorization": "Bearer zspat_..."},
json={
"attributes": [
{"option_id": 5, "option_value_ids": [50, 51]},
{"option_id": 6, "option_value_ids": [60]}
]
}
)
# 204 No Content on success
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/products/1/attributes", {
method: "PUT",
headers: {
"Authorization": "Bearer zspat_...",
"Content-Type": "application/json"
},
body: JSON.stringify({
attributes: [
{ option_id: 5, option_value_ids: [50, 51] },
{ option_id: 6, option_value_ids: [60] }
]
})
});
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/1/attributes")
req = Net::HTTP::Put.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "application/json"
req.body = {
attributes: [
{ option_id: 5, option_value_ids: [50, 51] },
{ option_id: 6, option_value_ids: [60] }
]
}.to_json
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
/admin/api/products/{id}/images
List all images for a product, ordered by sort_order.
{
"images": [
{
"id": 1,
"storage_key": "t_abc123/products/1/550e8400-e29b-41d4-a716-446655440000.webp",
"url": "https://cdn.zeroshop.io/t_abc123/products/1/550e8400-e29b-41d4-a716-446655440000.webp",
"alt_text": "",
"sort_order": 0
},
{
"id": 2,
"storage_key": "t_abc123/products/1/6ba7b810-9dad-11d1-80b4-00c04fd430c8.webp",
"url": "https://cdn.zeroshop.io/t_abc123/products/1/6ba7b810-9dad-11d1-80b4-00c04fd430c8.webp",
"alt_text": "Back view",
"sort_order": 1
}
]
}
curl "https://yourshop.zeroshop.io/admin/api/products/1/images" \
-H "Authorization: Bearer zspat_..."
import requests
resp = requests.get(
"https://yourshop.zeroshop.io/admin/api/products/1/images",
headers={"Authorization": "Bearer zspat_..."}
)
images = resp.json()["images"]
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/products/1/images", {
headers: { "Authorization": "Bearer zspat_..." }
});
const { images } = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/1/images")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer zspat_..."
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
images = JSON.parse(resp.body)["images"]
/admin/api/products/{id}/images
Upload a product image via multipart form. The image is resized to max 1200x1200px and re-encoded as WebP.
{
"file": "(binary image data — JPEG, PNG, GIF, or WebP)"
}
{
"id": 3,
"storage_key": "t_abc123/products/1/a1b2c3d4-e5f6-7890-abcd-ef1234567890.webp",
"url": "https://cdn.zeroshop.io/t_abc123/products/1/a1b2c3d4-e5f6-7890-abcd-ef1234567890.webp",
"alt_text": "",
"sort_order": 2
}
curl -X POST "https://yourshop.zeroshop.io/admin/api/products/1/images" \
-H "Authorization: Bearer zspat_..." \
-F "file=@product-photo.jpg"
import requests
with open("product-photo.jpg", "rb") as f:
resp = requests.post(
"https://yourshop.zeroshop.io/admin/api/products/1/images",
headers={"Authorization": "Bearer zspat_..."},
files={"file": ("product-photo.jpg", f, "image/jpeg")}
)
image = resp.json()
const formData = new FormData();
formData.append("file", fileInput.files[0]);
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/products/1/images", {
method: "POST",
headers: { "Authorization": "Bearer zspat_..." },
body: formData
});
const image = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/1/images")
boundary = "----FormBoundary#{SecureRandom.hex(8)}"
body = "--#{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"photo.jpg\"\r\nContent-Type: image/jpeg\r\n\r\n#{File.binread("photo.jpg")}\r\n--#{boundary}--\r\n"
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
req.body = body
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
image = JSON.parse(resp.body)
/admin/api/products/{id}/images/reorder
Reorder images for a product. All image IDs must belong to the product.
{
"ordered_ids": [
2,
1,
3
]
}
curl -X PUT "https://yourshop.zeroshop.io/admin/api/products/1/images/reorder" \
-H "Authorization: Bearer zspat_..." \
-H "Content-Type: application/json" \
-d '{"ordered_ids": [2, 1, 3]}'
import requests
resp = requests.put(
"https://yourshop.zeroshop.io/admin/api/products/1/images/reorder",
headers={"Authorization": "Bearer zspat_..."},
json={"ordered_ids": [2, 1, 3]}
)
# 204 No Content on success
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/products/1/images/reorder", {
method: "PUT",
headers: {
"Authorization": "Bearer zspat_...",
"Content-Type": "application/json"
},
body: JSON.stringify({ ordered_ids: [2, 1, 3] })
});
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/1/images/reorder")
req = Net::HTTP::Put.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "application/json"
req.body = { ordered_ids: [2, 1, 3] }.to_json
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
/admin/api/products/{id}/images/{img_id}
Delete a product image. Removes the record and the file from object storage.
curl -X DELETE "https://yourshop.zeroshop.io/admin/api/products/1/images/3" \
-H "Authorization: Bearer zspat_..."
import requests
resp = requests.delete(
"https://yourshop.zeroshop.io/admin/api/products/1/images/3",
headers={"Authorization": "Bearer zspat_..."}
)
# 204 No Content on success
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/products/1/images/3", {
method: "DELETE",
headers: { "Authorization": "Bearer zspat_..." }
});
require "net/http"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/1/images/3")
req = Net::HTTP::Delete.new(uri)
req["Authorization"] = "Bearer zspat_..."
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
/admin/api/files
List every digital file in the tenant's library.
{
"files": [
{
"id": 1,
"original_filename": "photography-guide-v2.pdf",
"file_size": 5242880,
"content_type": "application/pdf",
"sort_order": 0,
"created_at": "2025-11-20T14:30:00Z"
},
{
"id": 2,
"original_filename": "bonus-presets.zip",
"file_size": 15728640,
"content_type": "application/zip",
"sort_order": 1,
"created_at": "2025-11-21T09:00:00Z"
}
]
}
curl "https://yourshop.zeroshop.io/admin/api/files" \
-H "Authorization: Bearer zspat_..."
import requests
resp = requests.get(
"https://yourshop.zeroshop.io/admin/api/files",
headers={"Authorization": "Bearer zspat_..."}
)
files = resp.json()["files"]
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/files", {
headers: { "Authorization": "Bearer zspat_..." }
});
const { files } = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/files")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer zspat_..."
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
files = JSON.parse(resp.body)["files"]
/admin/api/files
Upload a new digital file via multipart form (single `file` field). Files are stored as-is (no processing) and added to the tenant's flat file library. To make a file available to customers, attach it to one or more access groups via PUT /admin/api/access-groups/{id}/files. Tier caps are enforced — exceeding the per-file or aggregate digital-bytes cap returns 402 with `error: tier_limit_exceeded`. The request body limit is 128 MiB.
{
"file": "(binary file data — any format)"
}
{
"id": 3,
"original_filename": "extra-resources.zip",
"file_size": 10485760,
"content_type": "application/zip",
"sort_order": 0,
"created_at": ""
}
curl -X POST "https://yourshop.zeroshop.io/admin/api/files" \
-H "Authorization: Bearer zspat_..." \
-F "file=@photography-guide.pdf"
import requests
with open("photography-guide.pdf", "rb") as f:
resp = requests.post(
"https://yourshop.zeroshop.io/admin/api/files",
headers={"Authorization": "Bearer zspat_..."},
files={"file": ("photography-guide.pdf", f, "application/pdf")}
)
digital_file = resp.json()
const formData = new FormData();
formData.append("file", fileInput.files[0]);
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/files", {
method: "POST",
headers: { "Authorization": "Bearer zspat_..." },
body: formData
});
const digitalFile = await resp.json();
require "net/http"
require "json"
require "securerandom"
uri = URI("https://yourshop.zeroshop.io/admin/api/files")
boundary = "----FormBoundary#{SecureRandom.hex(8)}"
file_data = File.binread("guide.pdf")
body = "--#{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"guide.pdf\"\r\nContent-Type: application/pdf\r\n\r\n#{file_data}\r\n--#{boundary}--\r\n"
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
req.body = body
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
/admin/api/files/{file_id}
Delete a digital file from the library. Removes the record and the underlying object from storage. Cascades to remove the file from any access groups it was attached to.
curl -X DELETE "https://yourshop.zeroshop.io/admin/api/files/3" \
-H "Authorization: Bearer zspat_..."
import requests
resp = requests.delete(
"https://yourshop.zeroshop.io/admin/api/files/3",
headers={"Authorization": "Bearer zspat_..."}
)
# 204 No Content on success
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/files/3", {
method: "DELETE",
headers: { "Authorization": "Bearer zspat_..." }
});
require "net/http"
uri = URI("https://yourshop.zeroshop.io/admin/api/files/3")
req = Net::HTTP::Delete.new(uri)
req["Authorization"] = "Bearer zspat_..."
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
/admin/api/products/{id}/recommendations
List all cross-sell and upsell recommendation links for a product.
[
{
"id": 1,
"source_product_id": 1,
"target_product_id": 5,
"recommendation_type": "cross_sell",
"sort_order": 0
},
{
"id": 2,
"source_product_id": 1,
"target_product_id": 8,
"recommendation_type": "cross_sell",
"sort_order": 1
},
{
"id": 3,
"source_product_id": 1,
"target_product_id": 12,
"recommendation_type": "upsell",
"sort_order": 0
}
]
curl "https://yourshop.zeroshop.io/admin/api/products/1/recommendations" \
-H "Authorization: Bearer zspat_..."
import requests
resp = requests.get(
"https://yourshop.zeroshop.io/admin/api/products/1/recommendations",
headers={"Authorization": "Bearer zspat_..."}
)
recommendations = resp.json()
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/products/1/recommendations", {
headers: { "Authorization": "Bearer zspat_..." }
});
const recommendations = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/1/recommendations")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer zspat_..."
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
recommendations = JSON.parse(resp.body)
/admin/api/products/{id}/recommendations
Replace all recommendations of a given type for a product. Pass an empty target list to clear that type.
{
"recommendation_type": "cross_sell",
"target_product_ids": [
5,
8,
15
]
}
curl -X PUT "https://yourshop.zeroshop.io/admin/api/products/1/recommendations" \
-H "Authorization: Bearer zspat_..." \
-H "Content-Type: application/json" \
-d '{
"recommendation_type": "cross_sell",
"target_product_ids": [5, 8, 15]
}'
import requests
resp = requests.put(
"https://yourshop.zeroshop.io/admin/api/products/1/recommendations",
headers={"Authorization": "Bearer zspat_..."},
json={
"recommendation_type": "cross_sell",
"target_product_ids": [5, 8, 15]
}
)
# 204 No Content on success
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/products/1/recommendations", {
method: "PUT",
headers: {
"Authorization": "Bearer zspat_...",
"Content-Type": "application/json"
},
body: JSON.stringify({
recommendation_type: "cross_sell",
target_product_ids: [5, 8, 15]
})
});
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/1/recommendations")
req = Net::HTTP::Put.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "application/json"
req.body = {
recommendation_type: "cross_sell",
target_product_ids: [5, 8, 15]
}.to_json
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
/admin/api/products/{id}/recommendations/{rec_id}
Delete a single recommendation link by its ID.
curl -X DELETE "https://yourshop.zeroshop.io/admin/api/products/1/recommendations/3" \
-H "Authorization: Bearer zspat_..."
import requests
resp = requests.delete(
"https://yourshop.zeroshop.io/admin/api/products/1/recommendations/3",
headers={"Authorization": "Bearer zspat_..."}
)
# 204 No Content on success
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/products/1/recommendations/3", {
method: "DELETE",
headers: { "Authorization": "Bearer zspat_..." }
});
require "net/http"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/1/recommendations/3")
req = Net::HTTP::Delete.new(uri)
req["Authorization"] = "Bearer zspat_..."
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
/admin/api/products/{id}/bundle-items
List all bundle components for a bundle product.
[
{
"id": 1,
"bundle_product_id": 10,
"component_product_id": 1,
"component_variant_id": 100,
"quantity": 1,
"sort_order": 0
},
{
"id": 2,
"bundle_product_id": 10,
"component_product_id": 3,
"component_variant_id": 300,
"quantity": 2,
"sort_order": 1
}
]
curl "https://yourshop.zeroshop.io/admin/api/products/10/bundle-items" \
-H "Authorization: Bearer zspat_..."
import requests
resp = requests.get(
"https://yourshop.zeroshop.io/admin/api/products/10/bundle-items",
headers={"Authorization": "Bearer zspat_..."}
)
bundle_items = resp.json()
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/products/10/bundle-items", {
headers: { "Authorization": "Bearer zspat_..." }
});
const bundleItems = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/10/bundle-items")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer zspat_..."
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
bundle_items = JSON.parse(resp.body)
/admin/api/products/{id}/bundle-items
Replace all bundle components for a product. Pass an empty items list to clear the bundle.
{
"items": [
{
"product_id": 1,
"variant_id": 100,
"quantity": 1
},
{
"product_id": 3,
"variant_id": 300,
"quantity": 2
}
]
}
curl -X PUT "https://yourshop.zeroshop.io/admin/api/products/10/bundle-items" \
-H "Authorization: Bearer zspat_..." \
-H "Content-Type: application/json" \
-d '{
"items": [
{ "product_id": 1, "variant_id": 100, "quantity": 1 },
{ "product_id": 3, "variant_id": 300, "quantity": 2 }
]
}'
import requests
resp = requests.put(
"https://yourshop.zeroshop.io/admin/api/products/10/bundle-items",
headers={"Authorization": "Bearer zspat_..."},
json={
"items": [
{"product_id": 1, "variant_id": 100, "quantity": 1},
{"product_id": 3, "variant_id": 300, "quantity": 2}
]
}
)
# 204 No Content on success
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/products/10/bundle-items", {
method: "PUT",
headers: {
"Authorization": "Bearer zspat_...",
"Content-Type": "application/json"
},
body: JSON.stringify({
items: [
{ product_id: 1, variant_id: 100, quantity: 1 },
{ product_id: 3, variant_id: 300, quantity: 2 }
]
})
});
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/10/bundle-items")
req = Net::HTTP::Put.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "application/json"
req.body = {
items: [
{ product_id: 1, variant_id: 100, quantity: 1 },
{ product_id: 3, variant_id: 300, quantity: 2 }
]
}.to_json
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
/admin/api/products/{id}/custom-fields
Get the custom fields currently linked to a product.
[
{
"id": 1,
"name": "Washing Instructions",
"field_type": "text",
"value": "Machine wash cold, tumble dry low"
},
{
"id": 2,
"name": "Country of Origin",
"field_type": "choice",
"value": "Portugal"
}
]
curl "https://yourshop.zeroshop.io/admin/api/products/1/custom-fields" \
-H "Authorization: Bearer zspat_..."
import requests
resp = requests.get(
"https://yourshop.zeroshop.io/admin/api/products/1/custom-fields",
headers={"Authorization": "Bearer zspat_..."}
)
custom_fields = resp.json()
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/products/1/custom-fields", {
headers: { "Authorization": "Bearer zspat_..." }
});
const customFields = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/1/custom-fields")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer zspat_..."
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
custom_fields = JSON.parse(resp.body)
/admin/api/products/{id}/custom-fields
Replace the set of custom fields linked to a product. Pass custom field IDs in display order.
{
"custom_field_ids": [
1,
2,
5
]
}
curl -X PUT "https://yourshop.zeroshop.io/admin/api/products/1/custom-fields" \
-H "Authorization: Bearer zspat_..." \
-H "Content-Type: application/json" \
-d '{"custom_field_ids": [1, 2, 5]}'
import requests
resp = requests.put(
"https://yourshop.zeroshop.io/admin/api/products/1/custom-fields",
headers={"Authorization": "Bearer zspat_..."},
json={"custom_field_ids": [1, 2, 5]}
)
# 204 No Content on success
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/products/1/custom-fields", {
method: "PUT",
headers: {
"Authorization": "Bearer zspat_...",
"Content-Type": "application/json"
},
body: JSON.stringify({ custom_field_ids: [1, 2, 5] })
});
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/1/custom-fields")
req = Net::HTTP::Put.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "application/json"
req.body = { custom_field_ids: [1, 2, 5] }.to_json
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
/admin/api/products/{id}/categories
Get the category IDs currently assigned to a product.
{
"category_ids": [
3,
7,
12
]
}
curl "https://yourshop.zeroshop.io/admin/api/products/1/categories" \
-H "Authorization: Bearer zspat_..."
import requests
resp = requests.get(
"https://yourshop.zeroshop.io/admin/api/products/1/categories",
headers={"Authorization": "Bearer zspat_..."}
)
category_ids = resp.json()["category_ids"]
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/products/1/categories", {
headers: { "Authorization": "Bearer zspat_..." }
});
const { category_ids } = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/1/categories")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer zspat_..."
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
category_ids = JSON.parse(resp.body)["category_ids"]
/admin/api/products/{id}/categories
Replace all category assignments for a product. Pass an empty list to clear all assignments.
{
"category_ids": [
3,
7,
12
]
}
curl -X PUT "https://yourshop.zeroshop.io/admin/api/products/1/categories" \
-H "Authorization: Bearer zspat_..." \
-H "Content-Type: application/json" \
-d '{"category_ids": [3, 7, 12]}'
import requests
resp = requests.put(
"https://yourshop.zeroshop.io/admin/api/products/1/categories",
headers={"Authorization": "Bearer zspat_..."},
json={"category_ids": [3, 7, 12]}
)
# 204 No Content on success
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/products/1/categories", {
method: "PUT",
headers: {
"Authorization": "Bearer zspat_...",
"Content-Type": "application/json"
},
body: JSON.stringify({ category_ids: [3, 7, 12] })
});
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/1/categories")
req = Net::HTTP::Put.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "application/json"
req.body = { category_ids: [3, 7, 12] }.to_json
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
/admin/api/products/bulk
Apply the same set of boolean field overrides to many products in a single round trip. Pass the product `ids` (1–500) and a `changes` object; only the `changes` fields you set are written, the rest are left untouched. At least one `changes` field must be present. Soft-deleted rows in `ids` are silently ignored. Returns the count of distinct live products that matched.
{
"ids": [
1,
2,
3
],
"changes": {
"visible": true,
"track_stock": false,
"requires_delivery_date": false,
"quick_order_enabled": true
}
}
{
"updated": 3
}
curl -X PATCH "https://yourshop.zeroshop.io/admin/api/products/bulk" \
-H "Authorization: Bearer zspat_..." \
-H "Content-Type: application/json" \
-d '{
"ids": [1, 2, 3],
"changes": { "visible": true, "quick_order_enabled": true }
}'
import requests
resp = requests.patch(
"https://yourshop.zeroshop.io/admin/api/products/bulk",
headers={"Authorization": "Bearer zspat_..."},
json={
"ids": [1, 2, 3],
"changes": {"visible": True, "quick_order_enabled": True}
}
)
result = resp.json()
print(f"Updated {result['updated']} products")
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/products/bulk", {
method: "PATCH",
headers: {
"Authorization": "Bearer zspat_...",
"Content-Type": "application/json"
},
body: JSON.stringify({
ids: [1, 2, 3],
changes: { visible: true, quick_order_enabled: true }
})
});
const result = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/bulk")
req = Net::HTTP::Patch.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "application/json"
req.body = {
ids: [1, 2, 3],
changes: { visible: true, quick_order_enabled: true }
}.to_json
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
result = JSON.parse(resp.body)
/admin/api/products/{id}/clone
Deep-clone an existing product, including its images (each image is server-side-copied to a fresh object-storage key under the new product so deletes on either side stay isolated). The clone is hidden by default. Counts against the tenant product cap — exceeding it returns 402 with `error: tier_limit_exceeded`, `limit: max_products`. Returns the new product's id and slug.
{
"id": 43,
"slug": "classic-cotton-tee-copy"
}
curl -X POST "https://yourshop.zeroshop.io/admin/api/products/1/clone" \
-H "Authorization: Bearer zspat_..."
import requests
resp = requests.post(
"https://yourshop.zeroshop.io/admin/api/products/1/clone",
headers={"Authorization": "Bearer zspat_..."}
)
clone = resp.json()
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/products/1/clone", {
method: "POST",
headers: { "Authorization": "Bearer zspat_..." }
});
const clone = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/1/clone")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer zspat_..."
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
clone = JSON.parse(resp.body)
/admin/api/products/create-import/example.csv
Download a filled-in example CSV template that demonstrates all 39 supported columns (Handle, Name, Description, Product Type, Price, variants, images, categories, …) with two example products. Use it as the starting point for a create-import.
"Handle,Name,Description,Product Type,Visible,Price,Compare At Price,SKU,Stock,Track Stock,Tax Class,Weight,Weight Unit,Length (cm),Width (cm),Height (cm),Requires Delivery Date,Categories,Cross Sell Handles,Image Src,Image Position,Image Alt Text,Option1 Name,Option1 Value,Option2 Name,Option2 Value,Option3 Name,Option3 Value,Variant SKU,Variant Price,Variant Compare At Price,Variant Inventory Qty,Variant Weight,Variant Weight Unit,Variant Length (cm),Variant Width (cm),Variant Height (cm),Variant Image,Variant Visible\nsimple-mug,Simple Mug,\"A plain ceramic mug, dishwasher safe.\",physical,true,9.99,,MUG-1,50,true,,300,g,,,,,Home > Kitchen,,https://placehold.co/600x600/png,1,Simple mug photo,,,,,,,,,,,,,,,,\nclassic-tee,Classic Tee,\"100% cotton unisex tee.\",physical,true,19.99,24.99,,,true,,200,g,,,,,Men > Apparel | Sale,simple-mug,https://placehold.co/600x600/png?text=Tee+Front,1,Tee front,Size,S,Color,Red,,TEE-S-RED,19.99,,12,,,,,https://placehold.co/600x600/png?text=Tee+Front,true\n"
curl "https://yourshop.zeroshop.io/admin/api/products/create-import/example.csv" \
-H "Authorization: Bearer zspat_..." \
-o product-create-example.csv
import requests
resp = requests.get(
"https://yourshop.zeroshop.io/admin/api/products/create-import/example.csv",
headers={"Authorization": "Bearer zspat_..."},
)
with open("product-create-example.csv", "wb") as f:
f.write(resp.content)
const resp = await fetch(
"https://yourshop.zeroshop.io/admin/api/products/create-import/example.csv",
{ headers: { "Authorization": "Bearer zspat_..." } }
);
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
require "net/http"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/create-import/example.csv")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer zspat_..."
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
File.binwrite("product-create-example.csv", resp.body)
/admin/api/products/create-import/preview
Upload a create-import CSV (multipart, single `file` field) and get a validation + planning preview. Nothing is written. Runs row-level, per-handle, and cross-row validation, plans the inserts against current DB state, and HEAD-checks every image URL (only when no other errors exist). Returns aggregate counts plus the full error/warning lists. `can_confirm` is true only when there are no errors. Max 50 MB and 10,000 rows.
| Name | Type | Required | Description |
|---|---|---|---|
| duplicate_mode | string | optional | How to treat handles that collide with existing live products. Sent as a multipart field, not a query param. "replace" soft-deletes the live product and recreates it; anything else (or absent) skips the duplicate. Default: skip. |
{
"file": "(create-import CSV with the 39-column header; up to 50 MB)"
}
{
"summary": {
"products_to_create": 2,
"variants_to_create": 3,
"images_to_fetch": 3,
"categories_to_create": [
"Home > Kitchen",
"Men > Apparel"
],
"options_to_create": [
"Size",
"Color"
]
},
"errors": [],
"warnings": [
{
"row": 4,
"message": "category \"Sale\" will be auto-created"
}
],
"can_confirm": true
}
curl -X POST "https://yourshop.zeroshop.io/admin/api/products/create-import/preview" \
-H "Authorization: Bearer zspat_..." \
-F "file=@products-to-create.csv" \
-F "duplicate_mode=skip"
import requests
with open("products-to-create.csv", "rb") as f:
resp = requests.post(
"https://yourshop.zeroshop.io/admin/api/products/create-import/preview",
headers={"Authorization": "Bearer zspat_..."},
files={"file": ("products-to-create.csv", f, "text/csv")},
data={"duplicate_mode": "skip"},
)
preview = resp.json()
print(preview["summary"], "can_confirm:", preview["can_confirm"])
const formData = new FormData();
formData.append("file", fileInput.files[0]);
formData.append("duplicate_mode", "skip");
const resp = await fetch(
"https://yourshop.zeroshop.io/admin/api/products/create-import/preview",
{
method: "POST",
headers: { "Authorization": "Bearer zspat_..." },
body: formData,
}
);
const preview = await resp.json();
require "net/http"
require "json"
require "securerandom"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/create-import/preview")
boundary = "----FormBoundary#{SecureRandom.hex(8)}"
csv_data = File.binread("products-to-create.csv")
body = "--#{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"products-to-create.csv\"\r\nContent-Type: text/csv\r\n\r\n#{csv_data}\r\n--#{boundary}\r\nContent-Disposition: form-data; name=\"duplicate_mode\"\r\n\r\nskip\r\n--#{boundary}--\r\n"
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
req.body = body
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
preview = JSON.parse(resp.body)
/admin/api/products/create-import/confirm
Re-upload the same CSV sent to `/create-import/preview` to apply it. The server re-parses and re-validates (the flow is stateless), then inserts all products, variants, categories, and options and fetches images in one pass. On validation failure it returns 422 with the same preview-shaped body (errors populated, created empty) so the UI can re-render. Image fetch failures are non-fatal and reported in `failed_images`. Applying the plan counts against the tenant product cap — exceeding it returns 402 with `error: tier_limit_exceeded`, `limit: max_products`. Max 50 MB and 10,000 rows.
| Name | Type | Required | Description |
|---|---|---|---|
| duplicate_mode | string | optional | Same as on /create-import/preview, sent as a multipart field. "replace" or skip (default). |
{
"file": "(same CSV uploaded to /create-import/preview; up to 50 MB)"
}
{
"created": [
{
"id": 42,
"handle": "simple-mug"
},
{
"id": 43,
"handle": "classic-tee"
}
],
"failed_images": [
{
"product_id": 43,
"url": "https://placehold.co/600x600/png?text=Tee+Back",
"reason": "fetch failed: 404 Not Found"
}
],
"errors": [],
"warnings": []
}
curl -X POST "https://yourshop.zeroshop.io/admin/api/products/create-import/confirm" \
-H "Authorization: Bearer zspat_..." \
-F "file=@products-to-create.csv" \
-F "duplicate_mode=skip"
import requests
with open("products-to-create.csv", "rb") as f:
resp = requests.post(
"https://yourshop.zeroshop.io/admin/api/products/create-import/confirm",
headers={"Authorization": "Bearer zspat_..."},
files={"file": ("products-to-create.csv", f, "text/csv")},
data={"duplicate_mode": "skip"},
)
result = resp.json()
print(f"Created {len(result['created'])} products, {len(result['failed_images'])} image failures")
const formData = new FormData();
formData.append("file", fileInput.files[0]);
formData.append("duplicate_mode", "skip");
const resp = await fetch(
"https://yourshop.zeroshop.io/admin/api/products/create-import/confirm",
{
method: "POST",
headers: { "Authorization": "Bearer zspat_..." },
body: formData,
}
);
const result = await resp.json();
require "net/http"
require "json"
require "securerandom"
uri = URI("https://yourshop.zeroshop.io/admin/api/products/create-import/confirm")
boundary = "----FormBoundary#{SecureRandom.hex(8)}"
csv_data = File.binread("products-to-create.csv")
body = "--#{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"products-to-create.csv\"\r\nContent-Type: text/csv\r\n\r\n#{csv_data}\r\n--#{boundary}\r\nContent-Disposition: form-data; name=\"duplicate_mode\"\r\n\r\nskip\r\n--#{boundary}--\r\n"
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
req.body = body
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
result = JSON.parse(resp.body)
We value your privacy
We use cookies for essential site functionality and, with your consent, analytics to understand how our platform is used. No personal data is shared with third parties. See our Privacy Policy for details.