Product
What GET /products and GET /products/{id} return. No webhook carries a
product — the catalogue is read, not pushed.
| Field | What it is |
|---|---|
| id string | The product's id, a UUID. |
| object "product" | Always the literal string. |
| title string | The product's name. |
| slug string | Its URL segment on the storefront — sailo.store/{handle}/p/{slug}. |
| description string | null | The seller's description, as plain text. |
| kind string | One of physical, digital, service, event, membership. It decides what the rest of the object carries: only service has a booking, only event has an event, only membership has a membership. |
| tags string[] | The seller's own labels. Normalised — lowercased and trimmed — before storage. |
| price money | What it sells for, in the shop's currency. |
| compareAt money | null | The struck-through 'was' price, where the seller set one. Null rather than equal to price when there is no comparison to draw. |
| trackInventory boolean | Whether Sailo counts stock for this product at all. |
| stock number | null | Units on hand, or null when trackInventory is false. Null means not counted, which is not the same statement as sold out — a consumer syncing levels into a marketplace listing must not read one as the other. |
| inStock boolean | Whether it can be bought right now. Already accounts for trackInventory, so this — not stock — is the field to gate a listing on. |
| isPublished boolean | Whether buyers can see it. A draft is invisible on the storefront but readable through the API. |
| isFeatured boolean | Whether the seller pinned it to the top of their shop. |
| booking object | null | Appointment settings, on a bookable product. Null on everything else. |
| event object | null | { startsAt } on a ticketed event. Null on everything else. |
| membership object | null | Recurring billing settings, on a membership product. Null on everything else. |
| variants object[] | Empty on GET /products — a page of twenty-five products with every variant inline is a large response nobody asked for. Populated on GET /products/{id}. |
| createdAt string | null | ISO 8601. |
| updatedAt string | null | ISO 8601. |
Stock
stock: null means not counted, not sold out. A product with
trackInventory: false reports null because Sailo is not tracking units — a
service, a digital download, a made-to-order item. A consumer that reads null as
zero will delist most of a typical shop.
Three fields, and only one of them is a listing gate:
| Field | Question it answers |
|---|---|
trackInventory | Is Sailo counting units at all? |
stock | How many, or null if not counting. |
inStock | Can a buyer buy this right now? |
inStock already accounts for trackInventory, so it is the field to gate on.
Use stock only for a number you display, and only where trackInventory is
true.
kind decides what else is present
Exactly one of the three optional objects, or none:
kind | Object |
|---|---|
service | booking |
event | event |
membership | membership |
physical, digital | all three null |
booking
| Field | What it is |
|---|---|
| durationMinutes number | null | How long one appointment runs. |
| serviceMode string | null | in_person or online. |
| leadHours number | null | How far ahead a buyer must book. A same-day cutoff, expressed in hours. |
event
| Field | What it is |
|---|---|
| startsAt string | null | ISO 8601, in UTC. Render it in the shop's timeZone. |
membership
| Field | What it is |
|---|---|
| interval string | null | month or year. |
| trialDays number | null | Free days before the first charge. Null or zero means none. |
A membership product is what a subscription is a
subscription to — the subscription’s productId points here.
Variants
| Field | What it is |
|---|---|
| id string | The variant's id — the value an order line's variantId carries. |
| sku string | null | The seller's own code for this variant. |
| options object | The choices that define it, as a plain object: { "Size": "Large", "Colour": "Blue" }. Keys are whatever the seller named their option groups. |
| price money | Always populated. A variant with no price of its own inherits the product's, so there is no null to handle. |
| stock number | null | Units of this variant, or null when the product does not track inventory. |
| isAvailable boolean | Whether this particular variant can be bought. |
Empty on the list endpoint, populated on the detail endpoint. A page of twenty-five products with every variant inline is a large response nobody asked for. If you are mirroring a catalogue with variants, page the list and then fetch detail for the products you actually mirror.
options is a plain object whose keys are whatever the seller named their
option groups — { "Size": "Large", "Colour": "Blue" }. Do not assume a fixed
set of keys, and do not assume every variant of a product carries the same ones.
price on a variant is always populated: one with no price of its own
inherits the product’s, so there is no null to handle.
slug and id
id is the key. slug is the product’s URL segment on the storefront —
sailo.store/{handle}/p/{slug} — and a seller can change it. Same rule as the
shop’s handle: store the id, treat the slug as display.
Drafts
isPublished: false is a draft: invisible on the storefront, readable through
the API. Correct for a key the seller minted themselves, but it means an
unfiltered mirror will publish things the seller has not. Filter with
?published=true unless you specifically want both.
Example
{
"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": "4c5d6e7f-8a9b-4c0d-9e1f-2a3b4c5d6e7f",
"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"
}