UnifyCart Developers

Products and Categories

Listing and reading products, filtering and sorting, pagination, and what the response contains.

Products and categories are read-only on the public API. Writing is done in the admin or by import.

Listing Products

GET /api/v1/products
Authorization: Bearer YOUR_TOKEN
curl -s https://shop.example.com/api/v1/products \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Accept: application/json"
Full response, one product of the page
{
  "current_page": 1,
  "data": [
    {
      "id": 38,
      "legacy_source_id": null,
      "name": "Rice",
      "slug": "rice",
      "description": "High-quality rice, a staple for various cuisines and a versatile base for many dishes.",
      "translations": null,
      "short_description": "High-quality rice, a staple for various cuisines and a versatile base for many dishes.",
      "sku": "DUM-00038",
      "barcode": null,
      "status": "active",
      "exclude_from_catalog": false,
      "type": "simple",
      "billing_interval": null,
      "billing_interval_count": 1,
      "free_trial_days": null,
      "setup_fee": null,
      "stripe_product_id": null,
      "stripe_price_id": null,
      "qbo_product_id": null,
      "price": "5.99",
      "allow_custom_price": false,
      "is_gift_card": false,
      "exclude_from_group_discounts": false,
      "min_custom_price": null,
      "compare_price": "6.60",
      "cost_price": "3.59",
      "show_lowest_price": false,
      "default_variant_id": null,
      "weight": "1.80",
      "length": null,
      "width": null,
      "height": null,
      "dimension_unit": null,
      "track_inventory": true,
      "stock_qty": 42,
      "damaged_qty": 0,
      "damaged_price": null,
      "allow_backorders": false,
      "low_stock_threshold": 5,
      "meta_title": null,
      "meta_description": null,
      "canonical_url": null,
      "og_image_url": null,
      "noindex": false,
      "sitemap_lastmod": null,
      "is_featured": false,
      "is_new": false,
      "is_preorder": false,
      "is_pinned": false,
      "is_taxable": true,
      "requires_shipping": true,
      "media_mail_eligible": false,
      "vendor_id": null,
      "shipping_origin_id": null,
      "brand_id": null,
      "tax_class_id": null,
      "tic_code": null,
      "sort_order": 37,
      "publish_at": null,
      "published_on": null,
      "unpublish_at": null,
      "created_at": "2026-10-03T08:07:48.000000Z",
      "updated_at": "2026-10-03T22:32:24.000000Z",
      "deleted_at": null,
      "max_downloads": 5,
      "download_expiry_days": 30
    }
  ],
  "first_page_url": "https://demo.unifycart.com/api/v1/products?page=1",
  "from": 1,
  "last_page": 3,
  "last_page_url": "https://demo.unifycart.com/api/v1/products?page=3",
  "links": [
    {
      "url": null,
      "label": "« Previous",
      "page": null,
      "active": false
    },
    {
      "url": "https://demo.unifycart.com/api/v1/products?page=1",
      "label": "1",
      "page": 1,
      "active": true
    },
    {
      "url": "https://demo.unifycart.com/api/v1/products?page=2",
      "label": "2",
      "page": 2,
      "active": false
    },
    {
      "url": "https://demo.unifycart.com/api/v1/products?page=3",
      "label": "3",
      "page": 3,
      "active": false
    },
    {
      "url": "https://demo.unifycart.com/api/v1/products?page=2",
      "label": "Next »",
      "page": 2,
      "active": false
    }
  ],
  "next_page_url": "https://demo.unifycart.com/api/v1/products?page=2",
  "path": "https://demo.unifycart.com/api/v1/products",
  "per_page": 24,
  "prev_page_url": null,
  "to": 24,
  "total": 50
}

Only active products are returned. Drafts are never exposed, which means a product your integration created as a draft will not appear until it is published.

Parameters

Parameter Does
q Matches name, description and SKU
category Filters to a category, by slug
featured Restricts to featured products
sort newest, price_asc, price_desc, name
per_page Page size, default 24
page Which page
GET /api/v1/products?category=books&sort=price_asc&per_page=50

Pagination

The response is a standard paginated envelope: the items in data, with current_page, last_page, total and per_page alongside.

Page through using last_page rather than requesting until you get an empty result. A catalogue that changes while you are paging can otherwise produce a short page in the middle.

Reading One Product

GET /api/v1/products/{idOrSlug}

Takes either the numeric id or the slug, which is useful when you hold a slug from a link and not an id.

The single product response includes its categories and variants, which the list does not.

curl -s https://shop.example.com/api/v1/products/38 \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Accept: application/json"
Full response
{
  "data": {
    "id": 38,
    "legacy_source_id": null,
    "name": "Rice",
    "slug": "rice",
    "description": "High-quality rice, a staple for various cuisines and a versatile base for many dishes.",
    "translations": null,
    "short_description": "High-quality rice, a staple for various cuisines and a versatile base for many dishes.",
    "sku": "DUM-00038",
    "barcode": null,
    "status": "active",
    "exclude_from_catalog": false,
    "type": "simple",
    "billing_interval": null,
    "billing_interval_count": 1,
    "free_trial_days": null,
    "setup_fee": null,
    "stripe_product_id": null,
    "stripe_price_id": null,
    "qbo_product_id": null,
    "price": "5.99",
    "allow_custom_price": false,
    "is_gift_card": false,
    "exclude_from_group_discounts": false,
    "min_custom_price": null,
    "compare_price": "6.60",
    "cost_price": "3.59",
    "show_lowest_price": false,
    "default_variant_id": null,
    "weight": "1.80",
    "length": null,
    "width": null,
    "height": null,
    "dimension_unit": null,
    "track_inventory": true,
    "stock_qty": 42,
    "damaged_qty": 0,
    "damaged_price": null,
    "allow_backorders": false,
    "low_stock_threshold": 5,
    "meta_title": null,
    "meta_description": null,
    "canonical_url": null,
    "og_image_url": null,
    "noindex": false,
    "sitemap_lastmod": null,
    "is_featured": false,
    "is_new": false,
    "is_preorder": false,
    "is_pinned": false,
    "is_taxable": true,
    "requires_shipping": true,
    "media_mail_eligible": false,
    "vendor_id": null,
    "shipping_origin_id": null,
    "brand_id": null,
    "tax_class_id": null,
    "tic_code": null,
    "sort_order": 37,
    "publish_at": null,
    "published_on": null,
    "unpublish_at": null,
    "created_at": "2026-10-03T08:07:48.000000Z",
    "updated_at": "2026-10-03T22:32:24.000000Z",
    "deleted_at": null,
    "max_downloads": 5,
    "download_expiry_days": 30,
    "categories": [
      {
        "id": 13,
        "legacy_source_id": null,
        "name": "Home & Kitchen",
        "slug": "home-kitchen",
        "description": "Furniture, appliances, and decor",
        "parent_id": null,
        "_lft": 25,
        "_rgt": 34,
        "image": null,
        "banner": null,
        "meta_title": null,
        "meta_description": null,
        "canonical_url": null,
        "og_image_url": null,
        "noindex": false,
        "sitemap_lastmod": null,
        "sort_order": 0,
        "is_active": true,
        "exclude_from_group_discounts": false,
        "created_at": "2026-10-03T08:07:46.000000Z",
        "updated_at": "2026-10-03T08:07:46.000000Z",
        "pivot": {
          "product_id": 38,
          "category_id": 13,
          "sort_order": 0,
          "created_at": "2026-10-03T08:07:48.000000Z",
          "updated_at": "2026-10-03T08:07:48.000000Z"
        }
      }
    ],
    "variants": []
  }
}

Prices are strings, not numbers

Money is returned as a decimal string to avoid the rounding that floating point introduces. Parse it with a decimal type. Reading "999.99" into a float and multiplying is how a penny goes missing on an invoice.

Variants

A variable product carries its editions in variants, each with its own id, SKU, price and stock.

The product's own price may be zero where every price lives on a variant or a required option. Do not assume the product price is what anybody pays: use the variant, or the lowest available, depending on what you are displaying.

Categories

GET /api/v1/categories
GET /api/v1/categories/{id}
curl -s https://shop.example.com/api/v1/categories \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Accept: application/json"
Full response, two categories of 26
{
  "data": [
    {
      "id": 26,
      "legacy_source_id": null,
      "name": "Automotive",
      "slug": "automotive",
      "description": "Parts, accessories, and tools",
      "parent_id": null,
      "_lft": 51,
      "_rgt": 52,
      "image": null,
      "banner": null,
      "meta_title": null,
      "meta_description": null,
      "canonical_url": null,
      "og_image_url": null,
      "noindex": false,
      "sitemap_lastmod": null,
      "sort_order": 0,
      "is_active": true,
      "exclude_from_group_discounts": false,
      "created_at": "2026-10-03T08:07:46.000000Z",
      "updated_at": "2026-10-03T08:07:46.000000Z",
      "products_count": 2
    },
    {
      "id": 24,
      "legacy_source_id": null,
      "name": "Beauty & Health",
      "slug": "beauty-health",
      "description": "Skincare, cosmetics, and wellness",
      "parent_id": null,
      "_lft": 47,
      "_rgt": 48,
      "image": null,
      "banner": null,
      "meta_title": null,
      "meta_description": null,
      "canonical_url": null,
      "og_image_url": null,
      "noindex": false,
      "sitemap_lastmod": null,
      "sort_order": 0,
      "is_active": true,
      "exclude_from_group_discounts": false,
      "created_at": "2026-10-03T08:07:46.000000Z",
      "updated_at": "2026-10-03T08:07:46.000000Z",
      "products_count": 5
    }
  ]
}

The list is flat and unpaginated: every category comes back in one response, and the tree is reconstructed from parent_id.

Categories are a tree. A product belongs to several, so summing sales by category gives a total larger than the order total. That is expected rather than a fault, and is explained in the merchant documentation.

Reading a Whole Catalogue

Page through once and keep what you got, rather than asking repeatedly.

page=1
while :; do
  curl -s "https://shop.example.com/api/v1/products?per_page=100&page=$page" \
    -H "Authorization: Bearer $TOKEN" > "page-$page.json"
  last=$(jq -r .last_page "page-$page.json")
  [ "$page" -ge "$last" ] && break
  page=$((page + 1))
done

Use last_page rather than reading until a page comes back empty. A catalogue being edited while you page can produce a short page in the middle, and a loop that stops on a short page stops early.

For a large shop, do this once and then use webhooks to learn about changes. Re-reading five thousand products hourly is how an integration gets rate limited on the merchant who can least afford it.

Stock on a Product

A product carries its stock, and a variable product carries stock per variant.

Whether something can actually be bought depends on more than the number: track_inventory off sells regardless, backorders sell past zero, and an inactive variant sells at no quantity. An integration deciding availability must read those too, or it will report things as out of stock that are selling normally.

Images

Media is returned with the product. The first image is the one the shop uses on listing tiles, in the basket and on the order, so take that one when you need a single representative image rather than picking arbitrarily.

A product with no image is common in a freshly migrated catalogue. Handle it rather than assuming one exists.

The Response Is the Whole Record

Every column on the row comes back, not a curated view of it. That includes fields that are none of an integration's business and some that are commercially sensitive: cost_price is what the merchant pays for the item, and qbo_product_id, stripe_product_id, legacy_source_id and deleted_at are internal bookkeeping.

Do Not Store or Display cost_price

A token with read_products can see the merchant's buying price. If you are building something a merchant's customers or staff will look at, read the fields you need and discard the rest, rather than passing the object through to a screen. A merchant will not expect their margin to be visible because they connected a shipping app.

Bind to the fields you actually use. Treat anything not documented here as liable to change, because a column added to the database turns up in this response without anybody deciding that it should.

What Is Not Here

Cost prices, supplier information and anything restricted to a customer group are not exposed on this endpoint, because the token belongs to the shop rather than to a customer.

If you need group-specific pricing, that is resolved at checkout and visible on the resulting order, not in the catalogue.