Managing Lists
Lists are static collections of profile record IDs — snapshots you control explicitly. Unlike dynamic audiences (which re-evaluate rule-based criteria against live data), lists are immutable until you add or remove members. Use them for suppression lists, seed cohorts, VIP groups, and any collection where you need to decide membership yourself.
When to use a list:
- Suppression — people who have unsubscribed, churned, or opted out of a channel
- Seeding — hand-curated high-value accounts for ABM campaigns
- Upload-based targeting — a CSV of customers from a trade show, event registration, or manual research
- Stable reference cohorts — control groups, beta program members
When to use an audience instead:
- The cohort is defined by behavior or profile attributes (e.g. "bought in the last 30 days")
- Membership should update automatically as profiles change
- You need real-time sizing → Audience Preview & Sizing
Creating a list
curl -X POST https://api.experiture.ai/public/v1/lists \
-H "Authorization: Bearer <your_access_token>" \
-H "Content-Type: application/json" \
-d '{
"name": "Email Unsubscribes — April 2026",
"description": "Profiles that opted out via the unsubscribe link in the April 2026 campaign"
}'Response:
{
"success": true,
"data": {
"id": "lst_01HXYZ",
"name": "Email Unsubscribes — April 2026",
"memberCount": 0,
"createdAt": "2026-04-21T15:30:00Z"
}
}Lists start empty. Add members separately.
Adding members
Add by contact key
The most common pattern — you know emails or customer IDs, not internal row IDs:
curl -X POST "https://api.experiture.ai/public/v1/lists/lst_01HXYZ/members:upsert" \
-H "Authorization: Bearer <your_access_token>" \
-H "Content-Type: application/json" \
-d '{
"contactKeys": [
"alice@example.com",
"bob@example.com",
"carol@example.com"
],
"normalizationMode": "email_lower_trim"
}'normalizationMode: "email_lower_trim" is the default and trims whitespace and lowercases email values before matching. The API resolves contact keys to profile row IDs internally.
Response:
{
"success": true,
"data": {
"listId": "lst_01HXYZ",
"memberCount": 1248,
"membershipVersion": 5,
"addedCount": 2,
"retainedCount": 0,
"removedCount": 0
}
}addedCount is newly added members. retainedCount is keys that were already members. Contact keys that don't match any profile are silently skipped — upsert the profile first, then add to the list.
Add up to 10,000 contact keys per request
For larger sets, batch your additions in chunks:
import os, requests
API_KEY = os.environ["EXPERITURE_API_KEY"]
BASE_URL = "https://api.experiture.ai/public/v1"
emails = load_unsubscribes_from_db() # returns list of email strings
CHUNK_SIZE = 1000
for i in range(0, len(emails), CHUNK_SIZE):
chunk = emails[i:i + CHUNK_SIZE]
resp = requests.post(
f"{BASE_URL}/lists/lst_01HXYZ/members:upsert",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
json={"contactKeys": chunk, "normalizationMode": "email_lower_trim"},
)
resp.raise_for_status()
result = resp.json()["data"]
print(f"Chunk {i//CHUNK_SIZE + 1}: added={result['addedCount']}, retained={result['retainedCount']}")Removing members
curl -X POST "https://api.experiture.ai/public/v1/lists/lst_01HXYZ/members:remove" \
-H "Authorization: Bearer <your_access_token>" \
-H "Content-Type: application/json" \
-d '{
"contactKeys": ["alice@example.com"],
"normalizationMode": "email_lower_trim"
}'Removal is silent for contact keys not currently in the list — no error is thrown. The response has the same shape as the upsert response, with removedCount reflecting how many members were removed.
Listing and reading lists
List all lists in the workspace:
curl "https://api.experiture.ai/public/v1/lists?page=1&pageSize=50" \
-H "Authorization: Bearer <your_access_token>"Response uses offset pagination. Use the page and pageSize query params. The response includes total so you can compute how many pages to fetch:
curl "https://api.experiture.ai/public/v1/lists?page=2&pageSize=50" \
-H "Authorization: Bearer <your_access_token>"Read a specific list:
curl https://api.experiture.ai/public/v1/lists/lst_01HXYZ \
-H "Authorization: Bearer <your_access_token>"Renaming and updating list metadata
curl -X PATCH https://api.experiture.ai/public/v1/lists/lst_01HXYZ \
-H "Authorization: Bearer <your_access_token>" \
-H "Content-Type: application/json" \
-d '{
"name": "Email Unsubscribes — Q2 2026",
"description": "Updated to reflect full Q2 scope"
}'PATCH is partial — omit fields you don't want to change.
Deleting a list
curl -X DELETE https://api.experiture.ai/public/v1/lists/lst_01HXYZ \
-H "Authorization: Bearer <your_access_token>"This removes the list and all membership records. Profiles themselves are not affected — deleting a list never modifies the profiles it referenced.
Suppression lists in practice
Suppression lists are the most common list use case. The pattern: maintain one list per suppression reason per channel, and check or sync these lists before campaign dispatch.
import os, datetime, requests
API_KEY = os.environ["EXPERITURE_API_KEY"]
BASE_URL = "https://api.experiture.ai/public/v1"
hdrs = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
# Create a channel-scoped suppression list
create_resp = requests.post(f"{BASE_URL}/lists", headers=hdrs, json={
"name": f"Email Suppressions — {datetime.date.today().isoformat()}",
"description": "Profiles who unsubscribed from marketing email",
})
create_resp.raise_for_status()
unsub_list_id = create_resp.json()["data"]["id"]
def handle_unsubscribe(email: str, event_id: str):
"""Called by the email provider's webhook when a user unsubscribes."""
requests.post(
f"{BASE_URL}/lists/{unsub_list_id}/members:upsert",
headers=hdrs,
json={"contactKeys": [email], "normalizationMode": "email_lower_trim"},
).raise_for_status()
# Also update the profile directly so the field is queryable
requests.post(
f"{BASE_URL}/records/profiles/upsert",
headers={**hdrs, "Idempotency-Key": f"unsub:{event_id}"},
json={
"record": {
"email": email,
"email_opted_out": True,
"email_opted_out_at": datetime.datetime.utcnow().isoformat() + "Z",
},
"matchKey": "email",
},
).raise_for_status()Building a list from an import
When importing a CSV of contacts, you can automatically create a list from all successfully imported profiles. See Importing Contacts into a List for the full workflow.
Rate limits and quotas
| Operation | Limit |
|---|---|
| Add members per request | 10,000 contact keys |
| Remove members per request | 10,000 contact keys |
For per-operation rate limits and list creation quotas, see rate limits documentation. For lists with very large membership counts, contact support — this is typically a sign you want a dynamic audience instead.
Common failure modes
404 CDP_ETL.NOT_FOUND — the listId doesn't exist or belongs to a different workspace. Verify with GET /lists.
CDP_ETL.VALIDATION.REQUEST_INVALID — the request body is malformed (e.g. empty contactKeys array or unsupported normalizationMode). Check the error.details field.
addedCount: 0 when you expect additions — the contact keys may not match any profile yet. Upsert the profiles first, then add them to the list.
List member count not updating immediately — memberCount in list reads may lag by up to 30 seconds after a large batch of additions. This is a counter cache; actual membership is accurate immediately for audience evaluation.
See Also
- Importing Contacts into a List — bulk-load a CSV and add to a list in one flow
- Audience Preview & Sizing — dynamic, rules-based alternative to static lists
- Lists API reference