Contacts
The people on a shop’s list — everyone who has bought something, signed up, or been added by an automation.
This is the only resource with writes. Both of them are here, and both are narrow on purpose.
Consent
Nothing on this page can mark anyone as consenting to marketing email. A
contact created through the API always has marketingConsentAt: null, whatever
you send. Consent is something a person gave, not something a request body can
assert.
To get consent, pass "sendOptIn": true on the create call. Sailo emails that
person the same double opt-in link the public signup form uses, and
marketingConsentAt is written when they click it — not when you asked.
The response carries optInSent, which says whether the email actually went
out. A false there can mean asked too often rather than failed: the opt-in
send is rate-limited per address, so a script that re-sends to the same person in
a loop is throttled rather than allowed to use Sailo to pester somebody.
Reading it back
marketingConsentAt is a timestamp or null. A timestamp because when is what
a regulator asks; null because that is a customer who bought a thing and never
agreed to be emailed.
Filter with ?consented=true when syncing into a newsletter tool:
curl "https://api.sailo.store/api/v1/contacts?consented=true&limit=100" \ -H "Authorization: Bearer sailo_sk_…"
Everybody the unfiltered list returns who is not in that set is somebody you
may not email. There is deliberately no way to ask for only the
non-consenting — that is not a list anybody should be assembling — so any other
value of consented is treated as absent rather than inverted.
Creating is idempotent by person
POST /contacts is create-or-update, matched on email or phone. Sending
somebody twice updates them and merges their tags rather than duplicating or
failing.
That is what makes it safe to put behind a form, a Typeform, or a Zapier action
that may fire twice. There is no separate update endpoint and no PUT, because
there is nothing for one to do.
Two rules on merging that are easy to get backwards:
- Tags are merged, never replaced. Sending
["webinar"]adds it and leaves whatever else they carry. - A name only ever fills a gap. If the contact already has a name — from the seller, or from an order — yours is ignored. An automation should not be able to overwrite what a human knows about a customer with what a form field guessed.
One of email or phone is required. If both are absent, that is an
invalid_request.
Tagging is its own endpoint
POST /contacts/{id}/tags takes add and remove.
Its own route rather than a field on the create call, because this is what an automation actually wants to do — tag everyone who turned up — and routing it through create-or-update would mean sending a name and an email you may not have just to change a label.
Add and remove, never replace. There is no way to set the whole tag set at once, on purpose: a tag the seller put on somebody by hand is not something an automation should be able to delete by omitting it.
Tags absent from the contact are not an error to remove, and tags already present are not an error to add. Both are no-ops, so the endpoint is safe to retry.
Tags are normalised — trimmed and lowercased — the same way stored ones are, on
both write and filter. A tag that normalises to nothing is an
invalid_request rather than a silent no-op.
What is deliberately absent
notes. The seller’s private scratchpad about a customer. It is the last
thing that should sync into a third-party CRM through an integration nobody
re-read, so it is not on the object at all.
Deletion. There is no DELETE /contacts/{id}. A person removed from a
seller’s list by an automation is not a mistake the seller can undo, and the
one legitimate case — a data-subject erasure request — is a decision that
belongs in the admin with a human attached to it.
The object
The full shape is on the contact object reference.
GET /contacts
consented=true is the filter that matters, and the one an integration pushing into Kit, Mailchimp or GoHighLevel must use: everybody else on this list is a customer who never agreed to be emailed. marketingConsentAt on each record is when they agreed, or null if they never did.
Parameters
| Parameter | What it does |
|---|---|
| tag query · string | Only contacts carrying this tag. Normalised the same way stored tags are. |
| email query · string (email) | Exact email, matched case-insensitively. |
| consented query · string | true narrows to people who opted in to marketing email. Any other value is treated as absent — there is no way to ask for only the non-consenting, because that is not a list anyone should be assembling. |
| 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/contacts?consented=true&limit=100" \ -H "Authorization: Bearer sailo_sk_…"
200 — a page of results
{
"data": [
{
"id": "c1d2e3f4-5a6b-7c8d-9e0f-1a2b3c4d5e6f",
"object": "contact",
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": null,
"tags": ["webinar"],
"source": "api",
"marketingConsentAt": "2026-08-12T10:03:55.700Z"
/* … every field GET /contacts/{id} returns … */
}
],
"has_more": false,
"next_cursor": null
}Failures
| code | When it happens |
|---|---|
| invalid_request HTTP 400 | tag normalises to nothing we could have stored. 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. |
POST /contacts write
The write that lets a form on your own site, a Typeform, or any Zapier action feed Sailo. Idempotent by person rather than by call: sending somebody twice updates them and merges their tags instead of duplicating or failing. A name only ever fills a gap — it never overwrites one the seller or an order already knows.
Request body
| Field | What it does |
|---|---|
| email string (email) · optional | Required unless phone is given. |
| phone string · optional | Required unless email is given. Normalised before storage. |
| name string · optional | Falls back to the email, then the phone, then Contact. |
| tags string[] · optional | Merged with any tags they already carry; never replaces them. At most 20 are kept, and the response says which. |
| sendOptIn boolean · optional | Email this person the same double opt-in link the public signup form uses. Needs an email address. Rate-limited per address, so a false in optInSent can mean asked too often rather than failed. |
{
"email": "ada@example.com",
"name": "Ada",
"tags": ["webinar"],
"sendOptIn": true
}Request
curl -X POST https://api.sailo.store/api/v1/contacts \
-H "Authorization: Bearer sailo_sk_…" \
-H "Content-Type: application/json" \
-d '{"email":"ada@example.com","name":"Ada","tags":["webinar"],"sendOptIn":true}'200
{
"data": {
"id": "c1d2e3f4-5a6b-7c8d-9e0f-1a2b3c4d5e6f",
"object": "contact",
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": null,
"tags": ["webinar"],
"source": "api",
"marketingConsentAt": null,
"address": { "line1": null, "line2": null, "city": null, "region": null, "postalCode": null, "country": null },
"createdAt": "2026-08-12T09:41:07.221Z",
"updatedAt": "2026-08-12T09:41:07.221Z",
"optInSent": true
}
}optInSent — Whether the confirmation email actually went out.
Failures
| code | When it happens |
|---|---|
| invalid_request HTTP 400 | Neither email nor phone, or an email we cannot store. The body is not a JSON object, or is over 64 KB. |
| forbidden HTTP 403 | The key is read-only. Mint one with write access. A real key, but the shop's plan does not include the API. |
| unauthorized HTTP 401 | No Authorization header, or a key we do not recognise. |
| 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 /contacts/{id}
One person on the list, with their tags and consent state. The seller's private notes column is deliberately absent — it is a scratchpad about a customer, and the last thing that should sync into a third-party CRM.
Parameters
| Parameter | What it does |
|---|---|
| id path · string (uuid) · required | The contact id. |
Request
curl https://api.sailo.store/api/v1/contacts/c1d2e3f4-5a6b-7c8d-9e0f-1a2b3c4d5e6f \ -H "Authorization: Bearer sailo_sk_…"
200
{
"data": {
"id": "c1d2e3f4-5a6b-7c8d-9e0f-1a2b3c4d5e6f",
"object": "contact",
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": null,
"tags": ["webinar"],
"source": "api",
"marketingConsentAt": null,
"address": {
"line1": null, "line2": null, "city": null,
"region": null, "postalCode": null, "country": null
},
"createdAt": "2026-08-12T09:41:07.221Z",
"updatedAt": "2026-08-12T09:41:07.221Z"
}
}Failures
| code | When it happens |
|---|---|
| not_found HTTP 404 | No contact 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. |
POST /contacts/{id}/tags write
Its own endpoint rather than a field on the upsert, because this is what an automation actually wants to do — tag everyone who turned up — and routing it through the upsert would mean sending a name and an email you may not have just to change a label. Add and remove, never replace: a tag the seller put on somebody by hand is not something an automation should delete by omitting it.
Parameters
| Parameter | What it does |
|---|---|
| id path · string (uuid) · required | The contact id. |
Request body
| Field | What it does |
|---|---|
| add string[] · optional | Tags to add. Already-present tags are left alone. |
| remove string[] · optional | Tags to take off. Absent tags are not an error. |
{
"add": ["vip", "attended"],
"remove": ["lead"]
}Request
curl -X POST https://api.sailo.store/api/v1/contacts/c1d2e3f4-5a6b-7c8d-9e0f-1a2b3c4d5e6f/tags \
-H "Authorization: Bearer sailo_sk_…" \
-H "Content-Type: application/json" \
-d '{"add":["vip"],"remove":["lead"]}'200
{
"data": {
"id": "c1d2e3f4-5a6b-7c8d-9e0f-1a2b3c4d5e6f",
"object": "contact",
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": null,
"tags": ["webinar"],
"source": "api",
"marketingConsentAt": null,
"address": {
"line1": null, "line2": null, "city": null,
"region": null, "postalCode": null, "country": null
},
"createdAt": "2026-08-12T09:41:07.221Z",
"updatedAt": "2026-08-12T09:41:07.221Z"
}
}Failures
| code | When it happens |
|---|---|
| invalid_request HTTP 400 | Neither add nor remove carried a usable tag. The body is not a JSON object, or is over 64 KB. |
| not_found HTTP 404 | No contact with that id in this shop. |
| forbidden HTTP 403 | The key is read-only. Mint one with write access. A real key, but the shop's plan does not include the API. |
| unauthorized HTTP 401 | No Authorization header, or a key we do not recognise. |
| 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. |