Lists and import
A list is a batch of contacts meant for a campaign. It is the only door for cold outreach: contacts go through WhatSetter’s verification and paced, anti-ban sending, never through a direct send.
Endpoints
Section titled “Endpoints”| Method | Path | Scope |
|---|---|---|
| GET | /lists |
lists:read |
| POST | /lists |
lists:write |
| POST | /lists/{id}/leads |
lists:write |
Create a list
Section titled “Create a list”curl -X POST "https://app.whatsetter.com/api/v1/lists" \ -H "Authorization: Bearer ws_live_…" \ -H "Content-Type: application/json" \ -d '{"name":"CRM sync, hot leads"}'const res = await fetch('https://app.whatsetter.com/api/v1/lists', { method: 'POST', headers: { Authorization: 'Bearer ws_live_…', 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'CRM sync, hot leads' }),});const { data: list } = await res.json();import requests
r = requests.post( "https://app.whatsetter.com/api/v1/lists", headers={"Authorization": "Bearer ws_live_…"}, json={"name": "CRM sync, hot leads"},)lst = r.json()["data"]{ "data": { "id": "66f1a2b3c4d5e6f7a8b9c0a1", "name": "CRM sync, hot leads", "status": "ready", "total_rows": 0, "valid_leads": 0, "invalid_leads": 0, "created_at": "2026-09-19T08:00:00.000Z" }}name is 1 to 255 characters. A workspace holds up to 200 lists; past that, 429 quota_exceeded.
Import leads
Section titled “Import leads”Up to 500 leads per call. Each lead needs a phone in international format. name, email and custom_variables are optional.
curl -X POST "https://app.whatsetter.com/api/v1/lists/66f1a2b3c4d5e6f7a8b9c0a1/leads" \ -H "Authorization: Bearer ws_live_…" \ -H "Content-Type: application/json" \ -d '{ "leads": [ { "phone": "+33612345678", "name": "Julien", "email": "julien@example.com", "custom_variables": { "city": "Lyon", "plan": "Pro" } }, { "phone": "+33698765432", "name": "Marie" } ] }'const res = await fetch('https://app.whatsetter.com/api/v1/lists/66f1a2b3c4d5e6f7a8b9c0a1/leads', { method: 'POST', headers: { Authorization: 'Bearer ws_live_…', 'Content-Type': 'application/json' }, body: JSON.stringify({ leads: [ { phone: '+33612345678', name: 'Julien', email: 'julien@example.com', custom_variables: { city: 'Lyon', plan: 'Pro' } }, { phone: '+33698765432', name: 'Marie' }, ], }),});const { data: result } = await res.json();import requests
r = requests.post( "https://app.whatsetter.com/api/v1/lists/66f1a2b3c4d5e6f7a8b9c0a1/leads", headers={"Authorization": "Bearer ws_live_…"}, json={ "leads": [ {"phone": "+33612345678", "name": "Julien", "email": "julien@example.com", "custom_variables": {"city": "Lyon", "plan": "Pro"}}, {"phone": "+33698765432", "name": "Marie"}, ] },)result = r.json()["data"]{ "data": { "imported": 2, "duplicates_in_list": 0, "rejected": [], "lead_ids": ["66f1a2b3c4d5e6f7a8b9c0b1", "66f1a2b3c4d5e6f7a8b9c0b2"] }}The status is 201 as soon as at least one lead was importable. lead_ids are the imported rows, in the order they were created. name and email are cut at 255 characters.
Deduplication and rejections
Section titled “Deduplication and rejections”| Case | What happens |
|---|---|
| Same number twice in the batch | The first one is kept, the other is listed in rejected with reason duplicate_in_batch. |
| Number already in this list | Not created again, counted in duplicates_in_list. |
| Invalid number | Listed in rejected with a reason starting with invalid_phone. A valid number is 8 to 15 digits, with or without +, spaces, dots, dashes or parentheses. |
custom_variables is not an object |
Rejected with reason custom_variables must be an object. |
custom_variables over 2000 characters once serialized |
Rejected with reason custom_variables exceeds 2000 characters. |
rejected entries carry the index of the lead in your array, so you can log or retry precisely.
Custom columns become variables
Section titled “Custom columns become variables”Every key of custom_variables becomes a per-lead variable your agent can use in its first message, written {{lead:key}} in the prompt editor. Keys are normalized to a slug: accents removed, lowercase, anything that is not a letter or digit becomes _. Ville du client and ville_du_client are the same variable.
With the example above, the prompt can say {{lead:city}} and {{lead:plan}}. A key missing on a given lead is reported as not provided to the agent, which is told never to invent a value.
Pagination
Section titled “Pagination”GET /lists returns the 100 most recent lists of the workspace, newest first, in data. There is no cursor on this endpoint.
{ "data": [ { "id": "66f1a2b3c4d5e6f7a8b9c0a1", "name": "CRM sync, hot leads", "status": "ready", "total_rows": 2, "valid_leads": 0, "invalid_leads": 0, "created_at": "2026-09-19T08:00:00.000Z" } ]}valid_leads and invalid_leads are filled as WhatSetter verifies the numbers after import.
How a list feeds a campaign
Section titled “How a list feeds a campaign”Importing does not send anything. The list is picked up by the campaign it is attached to in the dashboard: rows are verified, then sent at a human pace, within the number’s daily quota. Attach the list from your campaign in the dashboard, then follow the leads with GET /leads?campaign_id=.
Opt-in leads: the other door
Section titled “Opt-in leads: the other door”When a person just signed up on your funnel and expects a message now, use POST /campaigns/{id}/leads instead. It sends a personalized first message right away, with the same anti-ban protections, and lets you attach an external_reference.
Errors you will meet
Section titled “Errors you will meet”| HTTP | code |
When |
|---|---|---|
| 401 | missing_api_key, invalid_api_key |
Key absent, unknown or revoked. |
| 403 | insufficient_scope |
The key lacks lists:read or lists:write. |
| 404 | not_found |
Unknown list, or a list of another workspace. |
| 422 | validation_error |
Missing or too long name; leads not an array of 1 to 500; no importable lead in the batch (the message gives the first reason). |
| 429 | quota_exceeded |
200 lists already exist in the workspace. |
| 429 | rate_limited |
Too many requests this minute. Honor Retry-After. |

