Products
The catalogue, newest first.
stock: null means not counted
null is not zero. A product with trackInventory: false reports
stock: null, which means Sailo is not counting units — not that there are
none. A consumer syncing stock levels into a marketplace listing that reads one
as the other will delist every product the seller sells without counting, which
on a service or a digital download is most of them.
The field to gate a listing on is inStock, a boolean that already accounts
for trackInventory. Use stock for a number to display, and only where
trackInventory is true.
Variants are not expanded in the list
GET /products returns variants: [] on every row. GET /products/{id} returns
them in full.
A page of twenty-five products with every variant inline is a large response nobody asked for, and the detail endpoint is one call away for the product that matters. If you are syncing a catalogue with variants, the pattern is: page the list, then fetch detail for the products you are actually mirroring.
A variant with no price of its own inherits the product’s, so price on a
variant is always populated — there is no null to handle.
kind decides the rest of the object
One of physical, digital, service,
event, membership. It decides which of three optional
objects is present:
kind | Carries |
|---|---|
service | booking — duration, mode, lead time |
event | event — when it starts |
membership | membership — interval and trial days |
physical, digital | none of the three; all null |
Filter on it when your integration only handles one:
curl "https://api.sailo.store/api/v1/products?kind=physical&published=true" \ -H "Authorization: Bearer sailo_sk_…"
Drafts
?published=true is what buyers can see; ?published=false is drafts.
Omitting it means both, which is deliberate — a storefront sync wants one, a
catalogue audit wants the other, and neither should have to know which Sailo
picked as a default.
Drafts are invisible on the storefront and readable through the API. That is the correct behaviour for a key the seller minted themselves, but it does mean a public mirror built from an unfiltered list will publish things the seller has not.
Prices are in the shop’s currency
Products carry no currency of their own — a shop prices in one. Every money
object on every product carries the shop’s code, which you can read once from
GET /shop.
compareAt is the struck-through “was” price where the seller set one, and
null where they did not — not a copy of price.
The object
The full shape, including variants and the three optional objects, is on the product object reference.
GET /products
Variants are not expanded here — a page of twenty-five products with every variant inline is a large response nobody asked for, and the detail endpoint is one call away for the product that matters. stock is null on a product that does not track inventory, which means not counted and is not the same statement as sold out.
Parameters
| Parameter | What it does |
|---|---|
| kind query · string | What sort of thing it is. One of physical, digital, service, event, membership. |
| published query · boolean | true for what buyers can see, false for drafts. Omitting it means both — a storefront sync wants one, a catalogue audit wants the other, and neither should have to know which we picked as a default. |
| limit query · integer | How many to return. Defaults to 25, capped at 100 — asking for more is clamped, not refused. |
| cursor query · string | The next_cursor from the previous page. Omit for the first page. A cursor we did not issue is a 400, not an empty page. |
Request
curl "https://api.sailo.store/api/v1/products?published=true" \ -H "Authorization: Bearer sailo_sk_…"
200 — a page of results
{
"data": [
{
"id": "9a7e2c11-6b48-4d0f-8e35-71c9a4f2b6d8",
"object": "product",
"title": "Sourdough loaf",
"slug": "sourdough-loaf",
"kind": "physical",
"price": { "cents": 4999, "amount": "49.99", "currency": "GBP" },
"stock": 12,
"inStock": true,
"isPublished": true,
"variants": []
/* … every field GET /products/{id} returns, minus the variants … */
}
],
"has_more": false,
"next_cursor": null
}Failures
| code | When it happens |
|---|---|
| invalid_request HTTP 400 | cursor is not one we issued. |
| unauthorized HTTP 401 | No Authorization header, or a key we do not recognise. |
| forbidden HTTP 403 | A real key, but the shop's plan does not include the API. |
| rate_limited HTTP 429 | Too many calls on this key. Slow down and retry. |
| server_error HTTP 500 | Our fault. The body says nothing about the cause; retry. |
GET /products/{id}
The same product the list returns, plus its variants and their individual stock. A variant with no price of its own inherits the product's, so price is always populated.
Parameters
| Parameter | What it does |
|---|---|
| id path · string (uuid) · required | The product id. |
Request
curl https://api.sailo.store/api/v1/products/9a7e2c11-6b48-4d0f-8e35-71c9a4f2b6d8 \ -H "Authorization: Bearer sailo_sk_…"
200
{
"data": {
"id": "9a7e2c11-6b48-4d0f-8e35-71c9a4f2b6d8",
"object": "product",
"title": "Sourdough loaf",
"slug": "sourdough-loaf",
"description": "Baked the night before.",
"kind": "physical",
"tags": ["bread"],
"price": { "cents": 4999, "amount": "49.99", "currency": "GBP" },
"compareAt": null,
"trackInventory": true,
"stock": 12,
"inStock": true,
"isPublished": true,
"isFeatured": false,
"booking": null,
"event": null,
"membership": null,
"variants": [
{
"id": "4c5d…",
"sku": "SD-01-L",
"options": { "Size": "Large" },
"price": { "cents": 5499, "amount": "54.99", "currency": "GBP" },
"stock": 4,
"isAvailable": true
}
],
"createdAt": "2026-03-02T08:15:00.000Z",
"updatedAt": "2026-08-01T16:20:31.442Z"
}
}Failures
| code | When it happens |
|---|---|
| not_found HTTP 404 | No product with that id in this shop. |
| unauthorized HTTP 401 | No Authorization header, or a key we do not recognise. |
| forbidden HTTP 403 | A real key, but the shop's plan does not include the API. |
| rate_limited HTTP 429 | Too many calls on this key. Slow down and retry. |
| server_error HTTP 500 | Our fault. The body says nothing about the cause; retry. |