Contact Lists
Lists are your organization’s contact groups — the same lists under the Contacts → Lists tab and the audience your email campaigns send to. The tab is available to administrators when the email CRM is available. This section covers the endpoints to add a contact to a list, read its members and unsubscribe.
This is how an external system (a form, a checkout, a CRM, n8n, Make) puts people into SquadOS: the contact comes in with a valid email, a consent source and custom fields, and that fires the automations using the “Contact added to list” trigger.
All endpoints require the Authorization: Bearer pk_... header. When there’s a JSON body, also include Content-Type: application/json.
Base URL: https://api.squados.io/v1
See Authentication to get your key, and Errors for the code reference.
The List model
Section titled “The List model”{ "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "name": "Newsletter", "public_name": "Product updates", "description": "Signed up through the site footer", "kind": "marketing", "subscribed_count": 1284, "unsubscribed_count": 37, "created_at": "2026-08-01T12:00:00Z", "updated_at": "2026-08-19T09:31:00Z"}| Field | Type | Description |
|---|---|---|
id | uuid | List identifier. This is the listId in the endpoints below. |
name | string | Internal name, what your team sees in the panel. |
public_name | string | Name shown to the contact in the preference center. |
description | string or null | Internal note about the list. |
kind | string | marketing or transactional. |
subscribed_count | integer | Active subscribers. |
unsubscribed_count | integer | People who unsubscribed. |
created_at | date-time | List creation time in ISO 8601. |
updated_at | date-time | Last list change in ISO 8601. |
GET /lists
Section titled “GET /lists”Lists the organization’s lists, newest first.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
limit | integer | 50 | Maximum of 100. |
offset | integer | 0 | Pagination offset. |
Send non-negative integers and keep limit between 1 and 100. The response has no total, next or has_more: add the number of returned items to offset and stop when a page contains fewer items than limit.
curl -X GET "https://api.squados.io/v1/lists" \ -H "Authorization: Bearer pk_your_key_here"Response — 200 OK
{ "lists": [ { "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "name": "Newsletter", "public_name": "Product updates", "description": null, "kind": "marketing", "subscribed_count": 1284, "unsubscribed_count": 37, "created_at": "2026-08-01T12:00:00Z", "updated_at": "2026-08-19T09:31:00Z" } ], "limit": 50, "offset": 0}GET /lists/{listId}
Section titled “GET /lists/{listId}”Returns a single list.
Response — 200 OK
{ "list": { "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "name": "Newsletter", "public_name": "Product updates", "description": null, "kind": "marketing", "subscribed_count": 1284, "unsubscribed_count": 37, "created_at": "2026-08-01T12:00:00Z", "updated_at": "2026-08-19T09:31:00Z" }}An invalid listId returns 400 invalid_request. An internal, missing or other-organization list returns 404 not_found — a key only sees the organization that issued it.
POST /lists/{listId}/contacts
Section titled “POST /lists/{listId}/contacts”The main endpoint. Creates (or reuses) the contact by email and subscribes it to the list.
Path parameters
| Name | Type | Description |
|---|---|---|
listId | uuid | List ID. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
email | string | yes | Contact address. Normalized to lowercase — [email protected] and [email protected] are the same contact. |
consent_source | string | yes | Where this person consented to be contacted. 3 to 500 characters of free text (e.g. "footer newsletter form", "store checkout on 2026-08-12"). |
name | string | no | Contact display name, up to 200 characters. When sent non-empty, it updates the current name. |
metadata | object | no | Custom contact fields (see below). |
status | string | no | subscribed (default) or pending. |
curl -X POST "https://api.squados.io/v1/lists/3fa85f64-5717-4562-b3fc-2c963f66afa6/contacts" \ -H "Authorization: Bearer pk_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "email": "[email protected]", "name": "Mary Smith", "consent_source": "footer newsletter form on the website", "metadata": { "plan": "pro", "mrr": 199, "source": "google_ads" } }'Response — 201 Created
{ "contact": { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "name": "Mary Smith", "metadata": { "plan": "pro", "mrr": 199, "source": "google_ads" } }, "membership": { "id": "8a1b2c3d-4e5f-6789-abcd-ef0123456789", "list_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "status": "subscribed", "consent_source": "footer newsletter form on the website" }, "created": true}The call is idempotent
Section titled “The call is idempotent”Repeating the same email with the same status on the same list duplicates nothing: the response is 200 OK with "created": false. Even in that response, a non-empty name updates the name and metadata is merged into the contact. The created field describes whether the membership changed, not whether the contact was created: confirming pending as subscribed returns 201 and created: true while reusing the same membership.
metadata is merged, never replaced: sending {"plan": "enterprise"} on a second call changes only plan and preserves the other fields.
name: null, an empty string and metadata: {} do not erase existing values. To keep the request truly idempotent, always send the same name, metadata, status and consent source for the same external event.
Custom fields (metadata)
Section titled “Custom fields (metadata)”The fields you send become available in automations as {{contact.metadata.field}}.
Object rules:
- Flat object. Values can only be text, number, boolean or
null— no nested objects or arrays. - Keys in
[A-Za-z0-9_], up to 64 characters.total_valueworks;total-valueis rejected with400, because{{...}}interpolation can’t reach a hyphenated key — the field would exist and never be substituted. - At most 30 keys and 8 KB total.
Email campaigns do not accept contact.metadata.* as a personalization tag. The campaign catalog is closed to {{contact.first_name}}, {{contact.name}} and {{contact.email}}; any other tag blocks scheduling. Use metadata inside an automation when your flow needs it.
Double opt-in (status: "pending")
Section titled “Double opt-in (status: "pending")”With "status": "pending" the contact joins the list without receiving campaigns and without firing the automation. When the person confirms, call the same endpoint without status (or with subscribed): the membership becomes subscribed, and that’s when the automation fires.
Errors
Section titled “Errors”| Status | Code | When it happens |
|---|---|---|
400 | invalid_request | Invalid listId, JSON, body, email, name, consent_source, metadata or status. |
404 | not_found | The list doesn’t exist in this organization. |
409 | conflict | The address is on the organization’s suppression list (hard bounce or spam complaint), or the contact already unsubscribed from this list. |
500 | internal_error | Unexpected failure while creating/updating the contact or membership. The operation may be partial; retry with the same data. |
GET /lists/{listId}/contacts
Section titled “GET /lists/{listId}/contacts”Lists the members, newest first.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
status | string | — | Filter by subscribed, unsubscribed or pending. |
limit | integer | 50 | Maximum of 100. |
offset | integer | 0 | Pagination offset. |
This listing also has no total or cursor. Use non-negative integers, advance offset by the number of returned items and stop when the page is smaller than limit.
curl -X GET "https://api.squados.io/v1/lists/3fa85f64-5717-4562-b3fc-2c963f66afa6/contacts?status=subscribed&limit=100" \ -H "Authorization: Bearer pk_your_key_here"Response — 200 OK
{ "contacts": [ { "membership_id": "8a1b2c3d-4e5f-6789-abcd-ef0123456789", "status": "subscribed", "consent_source": "footer newsletter form on the website", "subscribed_at": "2026-08-20T14:02:00Z", "unsubscribed_at": null, "contact_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "name": "Mary Smith", "metadata": { "plan": "pro", "mrr": 199 } } ], "limit": 50, "offset": 0}contact_id is the same identifier used by the tag endpoints and by the contact panel.
In addition to global errors, this endpoint returns 400 invalid_request for an invalid listId or status, 404 not_found when the list is unavailable to the token and 500 internal_error when the member query fails.
DELETE /lists/{listId}/contacts/{contactId}
Section titled “DELETE /lists/{listId}/contacts/{contactId}”Unsubscribes the contact from the list.
curl -X DELETE "https://api.squados.io/v1/lists/3fa85f64-.../contacts/7c9e6679-..." \ -H "Authorization: Bearer pk_your_key_here"Response — 200 OK
{ "membership_id": "8a1b2c3d-...", "status": "unsubscribed", "changed": true }The membership becomes unsubscribed and the consent trail is preserved — the row is not deleted. Calling again returns 200 with "changed": false. The contact still exists in the organization, with its conversations and tags; it only leaves this list.
An invalid listId or contactId returns 400 invalid_request; a missing list or membership returns 404 not_found; failure to persist the unsubscribe returns 500 internal_error. Both subscribed and pending members are accepted, and the final state is unsubscribed in either case.
Firing an automation when a contact joins
Section titled “Firing an automation when a contact joins”This is the whole point of the endpoint. In the automation editor, use the “Contact added to list” trigger:
- Pick the list (leave it empty to match any list in the organization).
- Build the flow. From the very first step you have:
{{contact.first_name}},{{contact.display_name}},{{contact.identity_value}}(the email) and{{contact.metadata.field}}— the fields you sent in thePOST;{{trigger.payload.list_name}},{{trigger.payload.list_id}},{{trigger.payload.consent_source}}and{{trigger.payload.member_id}}.
- Publish and turn the automation on.
From then on, a new subscribed membership or a pending to subscribed transition attempts to queue a run for every active, published automation whose trigger matches the list. What does not fire: entering as pending, a request that finds the same status, and unsubscribes. The API response confirms the membership; it does not include a run_id or confirm that the automation finished.