Leads
A lead is one person your AI setter talks to. The API exposes the same record as the Leads tab of the dashboard: status, tags, owner, next action, deal outcome and notes.
Endpoints
Section titled “Endpoints”| Method | Path | Scope |
|---|---|---|
| GET | /leads |
leads:read |
| GET | /leads/{id} |
leads:read |
| PATCH | /leads/{id} |
leads:write |
| GET | /leads/{id}/notes |
leads:read |
| POST | /leads/{id}/notes |
leads:write |
| POST | /leads/{id}/handoff |
leads:write |
| POST | /leads/{id}/resume |
leads:write |
List and filter leads
Section titled “List and filter leads”Leads come back most recently active first, 25 per page by default.
curl "https://app.whatsetter.com/api/v1/leads?status=qualified&campaign_id=acme-france-1-ab12&limit=25" \ -H "Authorization: Bearer ws_live_…"const res = await fetch( 'https://app.whatsetter.com/api/v1/leads?status=qualified&campaign_id=acme-france-1-ab12&limit=25', { headers: { Authorization: 'Bearer ws_live_…' } },);const { data, pagination } = await res.json();import requests
r = requests.get( "https://app.whatsetter.com/api/v1/leads", params={"status": "qualified", "campaign_id": "acme-france-1-ab12", "limit": 25}, headers={"Authorization": "Bearer ws_live_…"},)data = r.json()["data"]{ "data": [ { "id": "66f1a2b3c4d5e6f7a8b9c0d1", "phone": "33612345678", "whatsapp_id": "33612345678@c.us", "name": "Julien", "status": "qualified", "qualified": true, "qualified_at": "2026-09-12T10:04:31.000Z", "replied": true, "campaign_id": "acme-france-1-ab12", "list_id": null, "channel": "whatsapp", "ig_username": null, "external_reference": "4471", "source": "api", "source_confidence": null, "tracked_link_id": null, "email": "julien@example.com", "tags": ["hot", "demo"], "messages_count": 14, "booking_status": "booked", "first_contact_at": "2026-09-10T08:00:12.000Z", "last_message_at": "2026-09-18T16:42:05.000Z", "last_message_direction": "inbound", "assigned_to": "66a1b2c3d4e5f6a7b8c9d0e1", "stage": "booked", "deal_status": "open", "lost_reason": null, "next_action_at": "2026-09-22T09:00:00.000Z", "next_action_label": "Call back", "created_at": "2026-09-10T07:59:58.000Z", "updated_at": "2026-09-18T16:42:05.000Z" } ], "pagination": { "has_more": true, "next_cursor": "66f1a2b3c4d5e6f7a8b9c0d1" }}phone is digits only, without the +. Pass cursor=next_cursor to get the next page.
Filters
Section titled “Filters”| Parameter | What it does |
|---|---|
status |
One of the statuses below. qualified returns every lead that was qualified at some point (its qualification date is set). |
campaign_id |
The campaign’s campaign_id slug or its id, both from GET /campaigns. |
phone |
Exact lookup, international format (+33612345678). |
external_reference |
Exact lookup on the id you sent with POST /campaigns/{id}/leads. |
source |
Where the lead came from: instagram, facebook, tiktok, youtube, linkedin, x, snapchat, pinterest, google, website, email, sms, qr, ads, other, direct, list, api, webhook, inbound, manual. |
tracked_link_id |
Only leads brought by this tracked link. |
channel |
whatsapp or instagram. |
since |
Only leads active after this ISO 8601 date (on last_message_at). |
limit, cursor |
Pagination, 1 to 100 per page. |
Status values
Section titled “Status values”status |
Meaning |
|---|---|
queued |
Imported, waiting for its first message. |
pending |
The agent stopped and waits for your team. |
in_progress |
The conversation is underway. |
qualified |
The agent validated interest, or you set it from the API. |
not_interested |
Wrong fit or explicit refusal. |
cold |
No reply after the follow-ups. |
stopped |
The conversation was stopped. |
unknown |
An internal value the public API does not name. |
Get one lead
Section titled “Get one lead”curl "https://app.whatsetter.com/api/v1/leads/66f1a2b3c4d5e6f7a8b9c0d1" \ -H "Authorization: Bearer ws_live_…"const res = await fetch('https://app.whatsetter.com/api/v1/leads/66f1a2b3c4d5e6f7a8b9c0d1', { headers: { Authorization: 'Bearer ws_live_…' },});const { data: lead } = await res.json();import requests
r = requests.get( "https://app.whatsetter.com/api/v1/leads/66f1a2b3c4d5e6f7a8b9c0d1", headers={"Authorization": "Bearer ws_live_…"},)lead = r.json()["data"]The response is { "data": lead } with the same object as above. A lead of another workspace answers 404, never 403.
Update a lead
Section titled “Update a lead”Send only the fields you change. At least one is required.
curl -X PATCH "https://app.whatsetter.com/api/v1/leads/66f1a2b3c4d5e6f7a8b9c0d1" \ -H "Authorization: Bearer ws_live_…" \ -H "Content-Type: application/json" \ -d '{ "status": "qualified", "tags": ["hot", "demo"], "assigned_to": "66a1b2c3d4e5f6a7b8c9d0e1", "next_action_at": "2026-09-22T09:00:00.000Z", "next_action_label": "Call back" }'const res = await fetch('https://app.whatsetter.com/api/v1/leads/66f1a2b3c4d5e6f7a8b9c0d1', { method: 'PATCH', headers: { Authorization: 'Bearer ws_live_…', 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'qualified', tags: ['hot', 'demo'], assigned_to: '66a1b2c3d4e5f6a7b8c9d0e1', next_action_at: '2026-09-22T09:00:00.000Z', next_action_label: 'Call back', }),});const { data: lead } = await res.json();import requests
r = requests.patch( "https://app.whatsetter.com/api/v1/leads/66f1a2b3c4d5e6f7a8b9c0d1", headers={"Authorization": "Bearer ws_live_…"}, json={ "status": "qualified", "tags": ["hot", "demo"], "assigned_to": "66a1b2c3d4e5f6a7b8c9d0e1", "next_action_at": "2026-09-22T09:00:00.000Z", "next_action_label": "Call back", },)lead = r.json()["data"]The response is the updated lead, 200.
| Field | Rules |
|---|---|
status |
qualified, in_progress, not_interested, cold, pending, stopped or queued. Setting qualified stamps qualified_at once. |
tags |
Array of up to 20 strings, 40 characters each. Replaces the whole list. Empty or too long tags are dropped, duplicates removed. |
assigned_to |
A member id from GET /team/members, or null to unassign. Unknown id: 422. |
next_action_at |
ISO 8601 date, or null. |
next_action_label |
Up to 80 characters, or null. Needs a next_action_at date (422 otherwise). Clearing the date clears the label. |
deal_status |
open, won or lost. |
lost_reason |
One of price, timing, unqualified, competitor, unreachable, other. Required with deal_status: "lost", cleared for open and won. |
Every change writes the lead’s journal, the same events the dashboard writes (owner, next_action, outcome), with your API key’s name as the actor.
Notes are the free-text entries your team sees on the lead’s record.
curl -X POST "https://app.whatsetter.com/api/v1/leads/66f1a2b3c4d5e6f7a8b9c0d1/notes" \ -H "Authorization: Bearer ws_live_…" \ -H "Content-Type: application/json" \ -d '{"text":"Called back, wants a quote before Friday."}'const res = await fetch('https://app.whatsetter.com/api/v1/leads/66f1a2b3c4d5e6f7a8b9c0d1/notes', { method: 'POST', headers: { Authorization: 'Bearer ws_live_…', 'Content-Type': 'application/json' }, body: JSON.stringify({ text: 'Called back, wants a quote before Friday.' }),});const { data: note } = await res.json();import requests
r = requests.post( "https://app.whatsetter.com/api/v1/leads/66f1a2b3c4d5e6f7a8b9c0d1/notes", headers={"Authorization": "Bearer ws_live_…"}, json={"text": "Called back, wants a quote before Friday."},)note = r.json()["data"]{ "data": { "id": "66f1a2b3c4d5e6f7a8b9c0e2", "text": "Called back, wants a quote before Friday.", "author": "API · My CRM", "created_at": "2026-09-19T08:12:00.000Z" }}text is 1 to 2000 characters. The author is API · <key name> for notes you create, or the teammate’s name for notes written in the dashboard. GET /leads/{id}/notes returns { "data": [ ...notes ] }, newest first, up to 100.
Hand a conversation to a human, then give it back
Section titled “Hand a conversation to a human, then give it back”POST /leads/{id}/handoff stops the AI agent on this lead. It stays silent until you call POST /leads/{id}/resume. This is the same switch as the dashboard’s take-over toggle, so the two never disagree.
curl -X POST "https://app.whatsetter.com/api/v1/leads/66f1a2b3c4d5e6f7a8b9c0d1/handoff" \ -H "Authorization: Bearer ws_live_…"const res = await fetch('https://app.whatsetter.com/api/v1/leads/66f1a2b3c4d5e6f7a8b9c0d1/handoff', { method: 'POST', headers: { Authorization: 'Bearer ws_live_…' },});const { data: control } = await res.json();import requests
r = requests.post( "https://app.whatsetter.com/api/v1/leads/66f1a2b3c4d5e6f7a8b9c0d1/handoff", headers={"Authorization": "Bearer ws_live_…"},)control = r.json()["data"]{ "data": { "id": "66f1a2b3c4d5e6f7a8b9c0d1", "external_reference": "4471", "agent_active": false, "manual_override": true, "status": "in_progress", "dashboard_synced": true, "updated_at": "2026-09-19T08:12:00.000Z" }}- The lead’s
statusis preserved: handing over never marks a lead as stopped or qualified. - Both calls are idempotent: a second handoff on a lead already handed over answers
200. dashboard_synced: falsemeans the agent is stopped but the Leads list may lag. The conversation view is always right.- The status rule: handoff and resume only apply to leads the agent has already engaged (
in_progress,stopped,cold,qualified,not_interested). Aqueuedorpendinglead has no conversation to hand over and answers409 invalid_state.
The stage field
Section titled “The stage field”stage is read-only and derived from the lead exactly like the dashboard’s funnel. It answers “where is this person commercially?” in one word, while status is the AI’s own vocabulary.
stage |
When |
|---|---|
contacted |
Written to, no answer yet. |
conversation |
The lead replied, the AI or a human is talking. |
waiting |
The AI stopped and waits for your team (status: pending). |
qualified |
Interest validated (status: qualified). |
booked |
A meeting is on the calendar, or was held. Wins over every other value. |
not_interested |
Wrong fit or refusal. |
cold |
No answer after the follow-ups. |
stopped |
The conversation was stopped. |
Correlate with your CRM
Section titled “Correlate with your CRM”Send your own record id as external_reference when you push an opt-in lead with POST /campaigns/{id}/leads. WhatSetter stores it verbatim, returns it on every lead and every webhook, and lets you look it up:
curl "https://app.whatsetter.com/api/v1/leads?external_reference=4471" \ -H "Authorization: Bearer ws_live_…"external_reference is null for leads that did not come from you (CSV imports, list imports, sales-page forms). For those, match on phone: digits only on both sides.
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 leads:read or leads:write. |
| 404 | not_found |
Unknown lead, or a lead of another workspace. |
| 409 | invalid_state |
Handoff or resume on a lead that was never contacted. |
| 422 | validation_error |
Unknown status, bad phone, unknown member id, empty body, invalid or expired cursor. |
| 429 | rate_limited |
Too many requests this minute. Honor Retry-After. |
| 502 | upstream_error |
The agent-control workflow was unreachable. Safe to retry. |

