Skip to Content
REST APIContacts

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.

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

Parameters for GET /contacts
ParameterWhat it does
tag query · stringOnly contacts carrying this tag. Normalised the same way stored tags are.
email query · string (email)Exact email, matched case-insensitively.
consented query · stringtrue 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 · integerHow many to return. Defaults to 25, capped at 100 — asking for more is clamped, not refused.
cursor query · stringThe 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

Failure modes for GET /contacts
codeWhen it happens
invalid_request HTTP 400tag normalises to nothing we could have stored. cursor is not one we issued.
unauthorized HTTP 401No Authorization header, or a key we do not recognise.
forbidden HTTP 403A real key, but the shop's plan does not include the API.
rate_limited HTTP 429Too many calls on this key. Slow down and retry.
server_error HTTP 500Our 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

Body fields for POST /contacts
FieldWhat it does
email string (email) · optionalRequired unless phone is given.
phone string · optionalRequired unless email is given. Normalised before storage.
name string · optionalFalls back to the email, then the phone, then Contact.
tags string[] · optionalMerged with any tags they already carry; never replaces them. At most 20 are kept, and the response says which.
sendOptIn boolean · optionalEmail 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
  }
}

optInSentWhether the confirmation email actually went out.

Failures

Failure modes for POST /contacts
codeWhen it happens
invalid_request HTTP 400Neither email nor phone, or an email we cannot store. The body is not a JSON object, or is over 64 KB.
forbidden HTTP 403The key is read-only. Mint one with write access. A real key, but the shop's plan does not include the API.
unauthorized HTTP 401No Authorization header, or a key we do not recognise.
rate_limited HTTP 429Too many calls on this key. Slow down and retry.
server_error HTTP 500Our 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

Parameters for GET /contacts/{id}
ParameterWhat it does
id path · string (uuid) · requiredThe 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

Failure modes for GET /contacts/{id}
codeWhen it happens
not_found HTTP 404No contact with that id in this shop.
unauthorized HTTP 401No Authorization header, or a key we do not recognise.
forbidden HTTP 403A real key, but the shop's plan does not include the API.
rate_limited HTTP 429Too many calls on this key. Slow down and retry.
server_error HTTP 500Our 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

Parameters for POST /contacts/{id}/tags
ParameterWhat it does
id path · string (uuid) · requiredThe contact id.

Request body

Body fields for POST /contacts/{id}/tags
FieldWhat it does
add string[] · optionalTags to add. Already-present tags are left alone.
remove string[] · optionalTags 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

Failure modes for POST /contacts/{id}/tags
codeWhen it happens
invalid_request HTTP 400Neither add nor remove carried a usable tag. The body is not a JSON object, or is over 64 KB.
not_found HTTP 404No contact with that id in this shop.
forbidden HTTP 403The key is read-only. Mint one with write access. A real key, but the shop's plan does not include the API.
unauthorized HTTP 401No Authorization header, or a key we do not recognise.
rate_limited HTTP 429Too many calls on this key. Slow down and retry.
server_error HTTP 500Our fault. The body says nothing about the cause; retry.
Last updated on