Skip to main content

Products

Read the catalog a site's store sells from: products, their variants, prices, and stock levels. Products are read-only over the API today; editing a product or adjusting stock changes what shoppers can buy, and that stays in the console.

Like orders, products belong to a site — each site has its own catalog.

Requires commerce on your plan

These endpoints need both the products:read scope and a plan that includes commerce. If your plan doesn't, they answer 403 plan_required with code: "commerce".

The product object

{
"id": "p_sourdough",
"object": "product",
"name": "Sourdough",
"slug": "sourdough",
"description": "Naturally leavened, 24-hour ferment.",
"type": "physical",
"status": "active",
"tags": ["bread", "bestseller"],
"categoryIds": ["cat_bakery"],
"mediaUrls": ["https://…/sourdough.jpg"],
"options": [{ "name": "Size", "values": ["Small", "Large"] }],
"variants": [
{
"id": "v_small",
"sku": "SD-S",
"barcode": null,
"options": { "Size": "Small" },
"priceUsd": 6,
"compareAtPriceUsd": null,
"weightGrams": 400,
"inventory": 12,
"inventoryTracked": true
},
{
"id": "v_large",
"sku": "SD-L",
"barcode": null,
"options": { "Size": "Large" },
"priceUsd": 9,
"compareAtPriceUsd": 11,
"weightGrams": 800,
"inventory": 0,
"inventoryTracked": true
}
],
"inventory": 12,
"subscription": null,
"created": "2026-03-02T11:00:00.000Z",
"updated": "2026-08-10T09:14:00.000Z"
}
FieldTypeNotes
idstringProduct id — use it in the paths below, and it's what an order's line item points at.
objectstringAlways "product".
namestring | nullDisplay name.
slugstring | nullURL segment on the storefront.
descriptionstring | nullLong description.
typestring | nullphysical, digital, or service.
statusstring | nulldraft, active, or archived — see statuses.
tags, categoryIdsarrayMerchandising.
mediaUrlsarrayImage URLs, first is the primary.
optionsarrayThe axes variants vary along, e.g. Size and Color.
variantsarrayWhere price and stock live — see variants. Never empty.
inventoryinteger | nullRoll-up across tracked variants only. null when nothing is tracked.
subscriptionobject | nullPresent when the product is sold as a recurring subscription: { "interval": "month" | "year", "trialDays": 14 }.
created, updatedstring | nullISO 8601.

Statuses

statusMeans
draftNot on the storefront. Being worked on.
activeOn sale.
archivedRetired, kept for history. Not on the storefront.

Products the merchant has deleted are never returned at all — not in the list, and a retrieve answers 404. Deletion is soft in our storage, but the API treats a deleted product as gone, because that's what the merchant meant.

Variants

Price and stock live on the variant, never on the product. A product with one option value still has exactly one variant — there is no such thing as a product-level price — so a client that reads product.priceUsd will find nothing. Always read product.variants.

FieldTypeNotes
idstring | nullVariant id. "default" on single-variant products.
sku, barcodestring | nullYours to set; Aglyn doesn't require or enforce uniqueness.
optionsobjectWhich option values this variant is, e.g. { "Size": "Large" }. {} on a single-variant product.
priceUsdnumber | nullPrice in dollars, as a decimal number — unlike orders, which are in integer cents.
compareAtPriceUsdnumber | nullThe struck-through "was" price, when one is set.
weightGramsnumber | nullFor shipping rates.
inventoryinteger | nullUnits left, or null — read the warning below.
inventoryTrackedbooleantrue when this variant counts stock at all.
inventory: null is not zero

null means stock isn't tracked for this variant — a consulting hour, a digital download, a made-to-order item. It is unlimited, not sold out.

0 means tracked and sold out.

Writing variant.inventory ?? 0 — the reflex — turns every untracked product into an out-of-stock one, and any "hide what's unavailable" rule downstream then hides your entire services catalog. Branch on inventoryTracked instead:

const available = !v.inventoryTracked || v.inventory > 0

The product-level inventory roll-up follows the same rule: it sums only tracked variants, and is null when none of them is tracked.

Prices are in dollars here and in cents on orders. That's not an inconsistency to route around — a catalog price is a decimal a merchant typed, and an order total is money that changed hands, which must never be a float. Convert explicitly at the boundary (Math.round(priceUsd * 100)) rather than assuming.

Endpoints

List products

GET /v1/sites/{siteId}/products — scope products:read. Paginated, ordered by product id.

ParamNotes
statusFilter to draft, active, or archived.
limit, cursorStandard pagination.
curl "https://app.aglyn.com/api/v1/sites/host_demo/products?status=active" \
-H "Authorization: Bearer aglyn_sk_…"

Deleted products are filtered out after the page is read, so a page can be shorter than limit while has_more is still true. Check has_more, not the length.

Retrieve a product

GET /v1/sites/{siteId}/products/{productId} — scope products:read.

curl "https://app.aglyn.com/api/v1/sites/host_demo/products/p_sourdough" \
-H "Authorization: Bearer aglyn_sk_…"

Recipes

A low-stock report

const LOW = 5
const lowStock = []
for (const product of await fetchAllProducts('host_demo', key)) {
if (product.status !== 'active') continue
for (const v of product.variants) {
if (!v.inventoryTracked) continue // untracked ≠ out of stock
if (v.inventory <= LOW) {
lowStock.push({
product: product.name,
variant: Object.values(v.options).join(' / ') || 'Default',
sku: v.sku,
left: v.inventory,
})
}
}
}

A product feed for a marketplace

const feed = products
.filter((p) => p.status === 'active')
.flatMap((p) =>
p.variants.map((v) => ({
id: `${p.id}:${v.id}`,
title: [p.name, Object.values(v.options).join(' / ')]
.filter(Boolean)
.join(' — '),
description: p.description ?? '',
image: p.mediaUrls[0] ?? null,
price: v.priceUsd,
sku: v.sku ?? undefined,
availability:
!v.inventoryTracked || v.inventory > 0 ? 'in stock' : 'out of stock',
})),
)

One feed row per variant, not per product — a marketplace buys a variant.

Errors

StatustypeWhen
403insufficient_scopeKey lacks products:read.
403plan_requiredPlan no longer includes commerce (code: "commerce").
404not_foundUnknown or unowned site; unknown or deleted product.
405method_not_allowedAnything other than GET.
  • Orders — sales of these products.
  • Media — the files behind mediaUrls.
  • Catalog — managing products in the console.
  • Conventions — pagination, ordering, errors.