Conventions
Rules that hold everywhere, so the reference pages do not have to repeat them.
The envelope
Every successful single-object answer:
{ "data": { … } }Every successful page:
{
"data": [ … ],
"has_more": true,
"next_cursor": "MjAyNi0wOC0xMlQwOTo0MTowNy4yMjFafDhmMmI"
}Every failure:
{ "error": { "code": "not_found", "message": "No order with that id." } }One shape every time, rather than one per endpoint. The consumers here are Zapier steps and language models, and both handle “the same envelope every time” far better than they handle prose.
The data wrapper is not decoration: it is what lets a page grow has_more and
next_cursor beside the array without the array itself having to become an
object, and it is what makes a body distinguishable from an error at a glance.
Response headers
| Header | Value |
|---|---|
content-type | application/json; charset=utf-8 |
sailo-version | 2026-08-12 — see versioning below |
cache-control | no-store, private |
no-store, private on every authenticated response, without exception.
Every one is scoped to one shop by a bearer token, and a shared cache keying on
the URL alone would serve one seller’s orders to another.
Identifiers
Every id is a UUID, stable for the life of the object, and unique across
shops. Two things follow:
- Store the
id, not the handle or the slug. A shop’shandleand a product’sslugare display values a seller can change. - Ids are safe to log. They name a Sailo object and nothing else. They are not credentials and they carry no shop’s identity.
An id from another shop is a not_found, never a forbidden. The two are
answered identically on purpose — distinguishing them would turn the API into a
way of asking whether an id exists somewhere else.
Money
Always an object, never a bare number:
| Field | What it is |
|---|---|
| cents number | The amount in the currency's minor unit, as an integer. Use this for arithmetic — it cannot lose a digit to floating point the way a decimal string parsed back to a number can. |
| amount string | The same amount as a decimal string, for anything a person will read. Currency-aware rather than a division by 100: JPY has no minor unit and JOD has three, so dividing by a fixed hundred is wrong for about a fifth of the currencies Sailo supports — and wrong by a factor of ten or a hundred when it is. |
| currency string | ISO 4217, uppercase. Always the shop's currency; Sailo prices one shop in one currency. |
"total": { "cents": 5498, "amount": "54.98", "currency": "GBP" }Both are sent because two readers want different things. A program wants
cents, which cannot lose a digit to floating point. A person wiring up a Zap
wants something they can drop straight into an email without a formatter step —
and the single most common integration bug in this category is that person
mapping the integer and mailing a customer “you paid 4999”.
amount is not cents / 100. It is currency-aware: JPY has no minor unit
at all and JOD has three, so a fixed hundred is wrong for about a fifth of the
currencies Sailo supports — and wrong by a factor of ten or a hundred when it
is. If you must compute a display value yourself, use amount.
A shop prices in one currency, so every money object on every object from one
shop carries the same currency. Read it from GET /shop once rather than per
row.
Dates and times
Every timestamp is ISO 8601 in UTC, with milliseconds:
"createdAt": "2026-08-12T09:41:07.221Z"Never a Unix epoch, never a local time, never a bare date. The one exception is
the webhook-timestamp header, which is seconds since epoch because Standard
Webhooks specifies it that way.
A shop has a timeZone — IANA, on GET /shop. Render timestamps in it for
anything a seller reads. An appointment at 09:00 in Europe/London is not 09:00
to a consumer in another zone, and a “today’s orders” report built on UTC
midnight is wrong for most of the world.
Nulls
A field that has no value is null. It is never absent, and never an empty
string.
That matters more than it sounds. JSON drops undefined entirely, so a payload
built from a partial object has a different set of keys from a complete one —
and Zapier builds its whole field map from the first payload it receives. A
seller who maps against a thin sample discovers the mismatch on their first real
sale. It is the reason the “Send test” button sends a complete payload with
every field a real one carries.
Nested objects follow the same rule with one deliberate exception. Where the
whole object is absent rather than empty — an order with no appointment — the
object itself is null rather than an object of nulls:
"booking": nullso that a consumer branching on booking gets a truthy test that works. Where
the object always exists but its contents may not — an order with no delivery
address — the object is present and its fields are null:
"address": { "line1": null, "line2": null, "city": null, … }The object reference says which is which, field by field.
Enumerations
String values, lowercase, snake_case where they have two words:
product_not_received, bank_transfer, in_person.
Treat every enumeration as open. New members are added — a payment rail, an order status, a dispute reason — and a consumer that throws on an unfamiliar value breaks on a change that broke nothing else. Branch on the members you handle and fall through to a default for the rest.
Naming
camelCase for fields on every object: paymentStatus, marketingConsentAt,
currentPeriodEnd.
snake_case for query parameters and paging keys: payment_status,
has_more, next_cursor. This is not an accident anybody forgot to fix — a
query string is a URL convention and a body is a JSON convention, and the two
worlds spell things differently. MCP tool arguments follow the query-string
spelling for the same reason.
Versioning
Two version numbers, and they answer different questions.
sailo-version — the REST API
A date, currently 2026-08-12, on every response. It changes only when something already sent stops meaning what it meant.
Adding a field is not a version change. Neither is adding an endpoint, a query parameter, an enumeration member, or an error code. Any of those would otherwise break every integration built on the last version, which is a tax on improvement nobody benefits from.
So: build your mapping to ignore fields it does not recognise. That is the one thing asked of a consumer in exchange for additive changes never breaking them.
version — the webhook envelope
An integer, currently 1, inside every webhook body. Same rule: bumped only when a field changes meaning or disappears, never when one is added.
What a breaking change would look like
If either ever changes, it will be announced on the changelog before it ships, and the old behaviour will keep working for a stated period. Nothing on this site describes a change that is coming; when one is, it will be there.
Character encoding
UTF-8 throughout, in both directions. Names, addresses and product titles carry whatever the seller and buyer typed — including emoji, right-to-left scripts and combining characters. Do not assume ASCII and do not assume one code unit per visible character.
Request bodies
Content-Type: application/json, capped at 64 KB. A body that is not
a JSON object, or is over the cap, is invalid_request — see
errors.