Guides
Managing Lists

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

OperationLimit
Add members per request10,000 contact keys
Remove members per request10,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 immediatelymemberCount 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