Organize products into categories with facets and ordering
/admin/api/categories
List all categories as a flat list ordered for tree rendering. Top-level categories come first, followed by children.
/admin/api/categories
Create a new category. A URL slug is auto-derived from the name. Supports optional parent_id for nesting and content translations.
/admin/api/categories/{id}
Update a category's name, parent, status, and translations. Re-derives the slug if the name changes. Rejects moves that would create a cycle.
/admin/api/categories/{id}
Delete a category. Returns 409 Conflict if products are currently assigned to it.
/admin/api/categories/{id}/status
Toggle a category's active/inactive status. Returns the updated category.
/admin/api/categories/reorder
Reorder categories within a single sibling group. Provide the parent_id (or null for top-level) and the complete ordered list of category IDs.
/admin/api/categories/{id}/facets
Get the option IDs whitelisted as storefront filters for this category.
/admin/api/categories/{id}/facets
Replace the facet option whitelist for a category. Send an empty array to disable all storefront filters.
/admin/api/categories/{id}/image
Upload or replace the category's hero image. Accepts multipart/form-data with a single field named "file". Images are resized to max 1200x1200 px and converted to WebP. Replaces any existing image.
/admin/api/categories/{id}/image
Remove the category's hero image. Idempotent — succeeds even if no image is set.
/admin/api/categories/export.csv
Stream the entire category tree as a UTF-8 CSV download (BOM-prefixed for Excel). Columns: id, parent_id, name, full_path (breadcrumb joined with " > "), and url (storefront path). Both active and inactive categories are included; there are no filters.
/admin/api/categories/import/example.csv
Download a small example CSV the import modal offers as a starting template. Contains three illustrative rows (a top-level category, a child, and a grandchild) with the import columns name, parent_path, status.
/admin/api/categories/import/preview
Upload a category CSV (multipart/form-data, single "file" field) to validate it in memory without writing anything. Returns a summary, the list of rows that would be created, and any per-row errors. Recognized columns are name (required), parent_path, and status; unknown columns (e.g. id, url from an export) are ignored. Parents are matched by full_path against existing categories and rows declared earlier in the same file.
/admin/api/categories/import/confirm
Upload a category CSV (multipart/form-data, single "file" field) and apply it. Re-parses and re-validates the file, then inserts every planned category inside a single transaction. By default any validation error aborts the whole import and returns 422 with a preview-shaped body. Pass ?skip_invalid_rows=true to import the valid rows and report the rest in skipped_rows.
/admin/api/categories
List all categories as a flat list ordered for tree rendering. Top-level categories come first, followed by children.
{
"categories": [
{
"id": 1,
"slug": "clothing",
"name": "Clothing",
"parent_id": null,
"sort_order": 0,
"status": "active"
},
{
"id": 2,
"slug": "t-shirts",
"name": "T-Shirts",
"parent_id": 1,
"sort_order": 0,
"status": "active"
},
{
"id": 3,
"slug": "accessories",
"name": "Accessories",
"parent_id": null,
"sort_order": 1,
"status": "inactive"
}
]
}
curl "https://yourshop.zeroshop.io/admin/api/categories" \
-H "Authorization: Bearer zspat_..."
import requests
resp = requests.get(
"https://yourshop.zeroshop.io/admin/api/categories",
headers={"Authorization": "Bearer zspat_..."}
)
categories = resp.json()
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/categories", {
headers: { "Authorization": "Bearer zspat_..." }
});
const data = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/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) }
data = JSON.parse(resp.body)
/admin/api/categories
Create a new category. A URL slug is auto-derived from the name. Supports optional parent_id for nesting and content translations.
{
"name": "T-Shirts",
"parent_id": 1,
"status": "active",
"translations": [
{
"locale": "de",
"field": "name",
"value": "T-Shirts"
}
]
}
{
"id": 2,
"slug": "t-shirts",
"name": "T-Shirts",
"parent_id": 1,
"sort_order": 0,
"status": "active",
"translations": {
"de": {
"name": "T-Shirts"
}
}
}
curl -X POST "https://yourshop.zeroshop.io/admin/api/categories" \
-H "Authorization: Bearer zspat_..." \
-H "Content-Type: application/json" \
-d '{
"name": "T-Shirts",
"parent_id": 1,
"status": "active",
"translations": [
{ "locale": "de", "field": "name", "value": "T-Shirts" }
]
}'
import requests
resp = requests.post(
"https://yourshop.zeroshop.io/admin/api/categories",
headers={"Authorization": "Bearer zspat_..."},
json={
"name": "T-Shirts",
"parent_id": 1,
"status": "active",
"translations": [
{"locale": "de", "field": "name", "value": "T-Shirts"}
]
}
)
category = resp.json()
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/categories", {
method: "POST",
headers: {
"Authorization": "Bearer zspat_...",
"Content-Type": "application/json"
},
body: JSON.stringify({
name: "T-Shirts",
parent_id: 1,
status: "active",
translations: [
{ locale: "de", field: "name", value: "T-Shirts" }
]
})
});
const category = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/categories")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "application/json"
req.body = {
name: "T-Shirts",
parent_id: 1,
status: "active",
translations: [
{ locale: "de", field: "name", value: "T-Shirts" }
]
}.to_json
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
category = JSON.parse(resp.body)
/admin/api/categories/{id}
Update a category's name, parent, status, and translations. Re-derives the slug if the name changes. Rejects moves that would create a cycle.
{
"name": "T-Shirts & Tops",
"parent_id": 1,
"status": "active",
"translations": [
{
"locale": "de",
"field": "name",
"value": "T-Shirts & Oberteile"
}
]
}
{
"id": 2,
"slug": "t-shirts-tops",
"name": "T-Shirts & Tops",
"parent_id": 1,
"sort_order": 0,
"status": "active",
"translations": {
"de": {
"name": "T-Shirts & Oberteile"
}
}
}
curl -X PUT "https://yourshop.zeroshop.io/admin/api/categories/2" \
-H "Authorization: Bearer zspat_..." \
-H "Content-Type: application/json" \
-d '{
"name": "T-Shirts & Tops",
"parent_id": 1,
"status": "active",
"translations": [
{ "locale": "de", "field": "name", "value": "T-Shirts & Oberteile" }
]
}'
import requests
resp = requests.put(
"https://yourshop.zeroshop.io/admin/api/categories/2",
headers={"Authorization": "Bearer zspat_..."},
json={
"name": "T-Shirts & Tops",
"parent_id": 1,
"status": "active",
"translations": [
{"locale": "de", "field": "name", "value": "T-Shirts & Oberteile"}
]
}
)
category = resp.json()
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/categories/2", {
method: "PUT",
headers: {
"Authorization": "Bearer zspat_...",
"Content-Type": "application/json"
},
body: JSON.stringify({
name: "T-Shirts & Tops",
parent_id: 1,
status: "active",
translations: [
{ locale: "de", field: "name", value: "T-Shirts & Oberteile" }
]
})
});
const category = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/categories/2")
req = Net::HTTP::Put.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "application/json"
req.body = {
name: "T-Shirts & Tops",
parent_id: 1,
status: "active",
translations: [
{ locale: "de", field: "name", value: "T-Shirts & Oberteile" }
]
}.to_json
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
category = JSON.parse(resp.body)
/admin/api/categories/{id}
Delete a category. Returns 409 Conflict if products are currently assigned to it.
curl -X DELETE "https://yourshop.zeroshop.io/admin/api/categories/3" \
-H "Authorization: Bearer zspat_..."
import requests
resp = requests.delete(
"https://yourshop.zeroshop.io/admin/api/categories/3",
headers={"Authorization": "Bearer zspat_..."}
)
# 204 No Content on success
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/categories/3", {
method: "DELETE",
headers: { "Authorization": "Bearer zspat_..." }
});
// 204 No Content on success
require "net/http"
uri = URI("https://yourshop.zeroshop.io/admin/api/categories/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/categories/{id}/status
Toggle a category's active/inactive status. Returns the updated category.
{
"status": "inactive"
}
{
"id": 2,
"slug": "t-shirts",
"name": "T-Shirts",
"parent_id": 1,
"sort_order": 0,
"status": "inactive"
}
curl -X PATCH "https://yourshop.zeroshop.io/admin/api/categories/2/status" \
-H "Authorization: Bearer zspat_..." \
-H "Content-Type: application/json" \
-d '{ "status": "inactive" }'
import requests
resp = requests.patch(
"https://yourshop.zeroshop.io/admin/api/categories/2/status",
headers={"Authorization": "Bearer zspat_..."},
json={"status": "inactive"}
)
category = resp.json()
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/categories/2/status", {
method: "PATCH",
headers: {
"Authorization": "Bearer zspat_...",
"Content-Type": "application/json"
},
body: JSON.stringify({ status: "inactive" })
});
const category = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/categories/2/status")
req = Net::HTTP::Patch.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "application/json"
req.body = { status: "inactive" }.to_json
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
category = JSON.parse(resp.body)
/admin/api/categories/reorder
Reorder categories within a single sibling group. Provide the parent_id (or null for top-level) and the complete ordered list of category IDs.
{
"parent_id": null,
"ordered_ids": [
1,
3
]
}
curl -X PUT "https://yourshop.zeroshop.io/admin/api/categories/reorder" \
-H "Authorization: Bearer zspat_..." \
-H "Content-Type: application/json" \
-d '{ "parent_id": null, "ordered_ids": [1, 3] }'
import requests
resp = requests.put(
"https://yourshop.zeroshop.io/admin/api/categories/reorder",
headers={"Authorization": "Bearer zspat_..."},
json={"parent_id": None, "ordered_ids": [1, 3]}
)
# 204 No Content on success
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/categories/reorder", {
method: "PUT",
headers: {
"Authorization": "Bearer zspat_...",
"Content-Type": "application/json"
},
body: JSON.stringify({ parent_id: null, ordered_ids: [1, 3] })
});
// 204 No Content on success
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/categories/reorder")
req = Net::HTTP::Put.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "application/json"
req.body = { parent_id: nil, ordered_ids: [1, 3] }.to_json
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
/admin/api/categories/{id}/facets
Get the option IDs whitelisted as storefront filters for this category.
{
"option_ids": [
1,
3,
5
]
}
curl "https://yourshop.zeroshop.io/admin/api/categories/1/facets" \
-H "Authorization: Bearer zspat_..."
import requests
resp = requests.get(
"https://yourshop.zeroshop.io/admin/api/categories/1/facets",
headers={"Authorization": "Bearer zspat_..."}
)
facets = resp.json()
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/categories/1/facets", {
headers: { "Authorization": "Bearer zspat_..." }
});
const facets = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/categories/1/facets")
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) }
facets = JSON.parse(resp.body)
/admin/api/categories/{id}/facets
Replace the facet option whitelist for a category. Send an empty array to disable all storefront filters.
{
"option_ids": [
1,
3,
5
]
}
curl -X PUT "https://yourshop.zeroshop.io/admin/api/categories/1/facets" \
-H "Authorization: Bearer zspat_..." \
-H "Content-Type: application/json" \
-d '{ "option_ids": [1, 3, 5] }'
import requests
resp = requests.put(
"https://yourshop.zeroshop.io/admin/api/categories/1/facets",
headers={"Authorization": "Bearer zspat_..."},
json={"option_ids": [1, 3, 5]}
)
# 204 No Content on success
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/categories/1/facets", {
method: "PUT",
headers: {
"Authorization": "Bearer zspat_...",
"Content-Type": "application/json"
},
body: JSON.stringify({ option_ids: [1, 3, 5] })
});
// 204 No Content on success
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/categories/1/facets")
req = Net::HTTP::Put.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "application/json"
req.body = { option_ids: [1, 3, 5] }.to_json
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
/admin/api/categories/{id}/image
Upload or replace the category's hero image. Accepts multipart/form-data with a single field named "file". Images are resized to max 1200x1200 px and converted to WebP. Replaces any existing image.
| Name | Type | Required | Description |
|---|---|---|---|
| file | file | required | The image file (JPEG, PNG, GIF, or WebP). |
{
"image_url": "https://cdn.zeroshop.io/tenants/abc/categories/7/7a3f1e.webp"
}
curl -X POST "https://yourshop.zeroshop.io/admin/api/categories/7/image" \
-H "Authorization: Bearer zspat_..." \
-F "file=@hero.jpg"
import requests
with open("hero.jpg", "rb") as f:
resp = requests.post(
"https://yourshop.zeroshop.io/admin/api/categories/7/image",
headers={"Authorization": "Bearer zspat_..."},
files={"file": f}
)
image = resp.json()
const form = new FormData();
form.append("file", fileInput.files[0]);
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/categories/7/image", {
method: "POST",
headers: { "Authorization": "Bearer zspat_..." },
body: form
});
const image = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/categories/7/image")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer zspat_..."
form = [["file", File.open("hero.jpg")]]
req.set_form(form, "multipart/form-data")
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
image = JSON.parse(resp.body)
/admin/api/categories/{id}/image
Remove the category's hero image. Idempotent — succeeds even if no image is set.
curl -X DELETE "https://yourshop.zeroshop.io/admin/api/categories/7/image" \
-H "Authorization: Bearer zspat_..."
import requests
resp = requests.delete(
"https://yourshop.zeroshop.io/admin/api/categories/7/image",
headers={"Authorization": "Bearer zspat_..."}
)
# 204 No Content on success
await fetch("https://yourshop.zeroshop.io/admin/api/categories/7/image", {
method: "DELETE",
headers: { "Authorization": "Bearer zspat_..." }
});
// 204 No Content on success
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/categories/7/image")
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) }
# 204 No Content on success
/admin/api/categories/export.csv
Stream the entire category tree as a UTF-8 CSV download (BOM-prefixed for Excel). Columns: id, parent_id, name, full_path (breadcrumb joined with " > "), and url (storefront path). Both active and inactive categories are included; there are no filters.
"id,parent_id,name,full_path,url\r\n1,,Men,Men,/c/men\r\n2,1,Apparel,Men > Apparel,/c/men/apparel\r\n3,2,T-Shirts,Men > Apparel > T-Shirts,/c/men/apparel/t-shirts\r\n"
curl "https://yourshop.zeroshop.io/admin/api/categories/export.csv" \
-H "Authorization: Bearer zspat_..." \
-o categories.csv
import requests
resp = requests.get(
"https://yourshop.zeroshop.io/admin/api/categories/export.csv",
headers={"Authorization": "Bearer zspat_..."}
)
with open("categories.csv", "wb") as f:
f.write(resp.content)
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/categories/export.csv", {
headers: { "Authorization": "Bearer zspat_..." }
});
const csv = await resp.text();
require "net/http"
uri = URI("https://yourshop.zeroshop.io/admin/api/categories/export.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.write("categories.csv", resp.body)
/admin/api/categories/import/example.csv
Download a small example CSV the import modal offers as a starting template. Contains three illustrative rows (a top-level category, a child, and a grandchild) with the import columns name, parent_path, status.
"name,parent_path,status\r\nMen,,active\r\nApparel,Men,active\r\nT-Shirts,Men > Apparel,active\r\n"
curl "https://yourshop.zeroshop.io/admin/api/categories/import/example.csv" \
-H "Authorization: Bearer zspat_..." \
-o categories-example.csv
import requests
resp = requests.get(
"https://yourshop.zeroshop.io/admin/api/categories/import/example.csv",
headers={"Authorization": "Bearer zspat_..."}
)
with open("categories-example.csv", "wb") as f:
f.write(resp.content)
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/categories/import/example.csv", {
headers: { "Authorization": "Bearer zspat_..." }
});
const csv = await resp.text();
require "net/http"
uri = URI("https://yourshop.zeroshop.io/admin/api/categories/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.write("categories-example.csv", resp.body)
/admin/api/categories/import/preview
Upload a category CSV (multipart/form-data, single "file" field) to validate it in memory without writing anything. Returns a summary, the list of rows that would be created, and any per-row errors. Recognized columns are name (required), parent_path, and status; unknown columns (e.g. id, url from an export) are ignored. Parents are matched by full_path against existing categories and rows declared earlier in the same file.
| Name | Type | Required | Description |
|---|---|---|---|
| file | file | required | The CSV file. Max 2 MB. Columns: name (required), parent_path (optional, e.g. "Men > Apparel"), status (optional, "active" or "inactive", defaults to "active"). |
{
"summary": {
"total_rows": 3,
"to_create": 2,
"errors": 1,
"skippable_error_rows": 1
},
"changes": [
{
"row": 2,
"name": "Apparel",
"parent_path": "Men",
"status": "active"
},
{
"row": 3,
"name": "T-Shirts",
"parent_path": "Men > Apparel",
"status": "active"
}
],
"errors": [
{
"row": 4,
"code": "PARENT_NOT_FOUND",
"message": "parent path \"Nope\" does not exist and is not created earlier in the CSV"
}
],
"can_confirm": false
}
curl -X POST "https://yourshop.zeroshop.io/admin/api/categories/import/preview" \
-H "Authorization: Bearer zspat_..." \
-F "file=@categories.csv"
import requests
with open("categories.csv", "rb") as f:
resp = requests.post(
"https://yourshop.zeroshop.io/admin/api/categories/import/preview",
headers={"Authorization": "Bearer zspat_..."},
files={"file": f}
)
preview = resp.json()
const form = new FormData();
form.append("file", fileInput.files[0]);
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/categories/import/preview", {
method: "POST",
headers: { "Authorization": "Bearer zspat_..." },
body: form
});
const preview = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/categories/import/preview")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer zspat_..."
form = [["file", File.open("categories.csv")]]
req.set_form(form, "multipart/form-data")
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
preview = JSON.parse(resp.body)
/admin/api/categories/import/confirm
Upload a category CSV (multipart/form-data, single "file" field) and apply it. Re-parses and re-validates the file, then inserts every planned category inside a single transaction. By default any validation error aborts the whole import and returns 422 with a preview-shaped body. Pass ?skip_invalid_rows=true to import the valid rows and report the rest in skipped_rows.
| Name | Type | Required | Description |
|---|---|---|---|
| skip_invalid_rows | boolean | optional | When true, rows with errors are skipped and the valid rows are still imported. When false (default), any error aborts the import with a 422. |
| file | file | required | The CSV file. Same format as the preview endpoint. Max 2 MB. |
{
"created": 2,
"skipped": 0,
"skipped_rows": []
}
curl -X POST "https://yourshop.zeroshop.io/admin/api/categories/import/confirm?skip_invalid_rows=true" \
-H "Authorization: Bearer zspat_..." \
-F "file=@categories.csv"
import requests
with open("categories.csv", "rb") as f:
resp = requests.post(
"https://yourshop.zeroshop.io/admin/api/categories/import/confirm",
headers={"Authorization": "Bearer zspat_..."},
params={"skip_invalid_rows": "true"},
files={"file": f}
)
result = resp.json()
const form = new FormData();
form.append("file", fileInput.files[0]);
const resp = await fetch(
"https://yourshop.zeroshop.io/admin/api/categories/import/confirm?skip_invalid_rows=true",
{
method: "POST",
headers: { "Authorization": "Bearer zspat_..." },
body: form
}
);
const result = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/categories/import/confirm?skip_invalid_rows=true")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer zspat_..."
form = [["file", File.open("categories.csv")]]
req.set_form(form, "multipart/form-data")
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.