# Leads

The feed everything else hangs off, the conversation behind a lead, and the writes that move one along.

Source: https://docs.leadarc.io/reference-leads.html

- [GET /v1/workspaces/{ws}/leads](#get-v1-workspaces-ws-leads)
- [GET /v1/workspaces/{ws}/leads/{lead}](#get-v1-workspaces-ws-leads-lead)
- [GET /v1/workspaces/{ws}/leads/{lead}/replies](#get-v1-workspaces-ws-leads-lead-replies)
- [GET /v1/workspaces/{ws}/leads/{lead}/messages](#get-v1-workspaces-ws-leads-lead-messages)
- [GET /v1/workspaces/{ws}/leads/{lead}/assignees](#get-v1-workspaces-ws-leads-lead-assignees)
- [PUT /v1/workspaces/{ws}/leads/{lead}/assignees](#put-v1-workspaces-ws-leads-lead-assignees)
- [POST /v1/workspaces/{ws}/leads/{lead}/status](#post-v1-workspaces-ws-leads-lead-status)
- [POST /v1/workspaces/{ws}/leads/{lead}/read](#post-v1-workspaces-ws-leads-lead-read)
- [POST /v1/workspaces/{ws}/leads/{lead}/unread](#post-v1-workspaces-ws-leads-lead-unread)
- [POST /v1/workspaces/{ws}/leads/{lead}/responded](#post-v1-workspaces-ws-leads-lead-responded)
- [POST /v1/workspaces/{ws}/leads/{lead}/booked](#post-v1-workspaces-ws-leads-lead-booked)
- [POST /v1/workspaces/{ws}/leads/{lead}/disqualified](#post-v1-workspaces-ws-leads-lead-disqualified)

## GET /v1/workspaces/{ws}/leads

`leads:read` · read bucket · returns list of lead

Filtered, paginated lead feed, one row per lead

```bash
curl -sS https://api.leadarc.io/v1/workspaces/airpay/leads?limit=25 \
  -H "Authorization: Bearer $LEADARC_API_KEY"
```

**Query parameters.** Anything else is [`400 unsupported_filter`](errors.html#status-400).

`limit` `cursor` `order` `expand` `status` `campaign_id` `assignee_id` `email` `has_open_task` `task_due_before` `meeting_outcome` `direction` `updated_since` `updated_before` `unread` `responded`

**Errors from this endpoint**, on top of the [common ones](errors.html): [`invalid_cursor`](errors.html#status-400), [`invalid_request`](errors.html#status-400), [`not_found`](errors.html#status-404), `unsupported_filter`.

```
GET /v1/workspaces/airpay/leads?status=interested&has_open_task=false&limit=25
```

```json
{
  "object": "list",
  "data": [{
    "object": "lead",
    "id": "lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny",
    "workspace": "airpay",
    "email": "m.adams@northgatecare.com",
    "first_name": "Mary",
    "last_name": "Adams",
    "company_name": "Northgate Care",
    "title": "Director of Operations",
    "status": "interested",
    "classification": "INTERESTED",
    "state": "active",
    "subject": "Re: quick question about your billing flow",
    "body_preview": "Sure, happy to take a look. What does onboarding look like on your side?",
    "latest_reply_id": "rep_Dk9dw7dVABGNJm9HK9onvZKXLneRRpj9MIB-C3",
    "last_message_at": "2026-07-27T09:14:22Z",
    "last_message_direction": "received",
    "last_action_at": "2026-07-27T09:22:00Z",
    "booked_at": null,
    "disqualified_at": null,
    "meeting_outcome": null,
    "meeting_outcome_at": null,
    "responded": false,
    "unread": true,
    "campaign_id": "cmp_HWO-6oxZflAfh5iwoKTzqjgycG_C8wMff6Galz",
    "campaign_name": "Senior Living / Ops Directors / v3",
    "assignee_ids": ["usr_zaDRRpdu8EPZSU3i89lvZgKPkVQo4uBFA_KOhm"],
    "has_open_task": false,
    "next_due_at": null,
    "indexed_at": "2026-07-27T09:14:31Z"
  }],
  "has_more": true,
  "next_cursor": "MjAyNi0wNy0yN1QwOToxNDoyMi4wMDBafDk5MTIwNDE"
}
```
**Caveats.**

1. **`campaign_id` and `campaign_name` are null on recent and historical rows.** Historical rows stay null and **there is no backfill**. So `?campaign_id=` misses every lead from the last 15 minutes and every lead reconcile has not re-touched.

2. **`unread`, `has_open_task`, and `next_due_at` are `key-scoped`.** They are computed for the key's owner. Two keys owned by two different people return different values for the same lead.

3. **`status` is `best-effort` at the lead level.** A tag an agent just wrote can change with no caller action if a sibling reply is tagged.

## GET /v1/workspaces/{ws}/leads/{lead}

`leads:read` · read bucket · returns lead

One lead with rollups

```bash
curl -sS https://api.leadarc.io/v1/workspaces/airpay/leads/lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny?limit=25 \
  -H "Authorization: Bearer $LEADARC_API_KEY"
```

**Query parameters.** Anything else is `400 unsupported_filter`.

`expand` `include`

**Errors from this endpoint**, on top of the [common ones](errors.html): `invalid_request`, `not_found`, `unsupported_filter`.

## GET /v1/workspaces/{ws}/leads/{lead}/replies

`leads:read` · read bucket · returns list of reply

Every indexed inbound reply from this lead

```bash
curl -sS https://api.leadarc.io/v1/workspaces/airpay/leads/lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny/replies?limit=25 \
  -H "Authorization: Bearer $LEADARC_API_KEY"
```

**Query parameters.** Anything else is `400 unsupported_filter`.

`limit` `cursor` `order`

**Errors from this endpoint**, on top of the [common ones](errors.html): `invalid_cursor`, `not_found`, `unsupported_filter`.

```json
{
  "object": "list",
  "data": [{
    "object": "reply",
    "id": "rep_Dk9dw7dVABGNJm9HK9onvZKXLneRRpj9MIB-C3",
    "lead_id": "lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny",
    "workspace": "airpay",
    "email": "m.adams@northgatecare.com",
    "subject": "Re: quick question about your billing flow",
    "body_preview": "Sure, happy to take a look. What does onboarding look like on your side?",
    "status": "interested",
    "classification": "INTERESTED",
    "direction": "received",
    "received_at": "2026-07-27T09:14:22Z",
    "campaign_id": "cmp_HWO-6oxZflAfh5iwoKTzqjgycG_C8wMff6Galz",
    "campaign_name": "Senior Living / Ops Directors / v3"
  }],
  "has_more": false,
  "next_cursor": null
}
```
A [`reply`](objects.html#reply) is one indexed inbound email. It is the atomic unit `responses_index` is keyed on (`eb_reply_id` is the primary key). It is **not** the full conversation. Use `/messages` for that.

## GET /v1/workspaces/{ws}/leads/{lead}/messages

`threads:read` · read bucket · returns list of message

Full merged conversation, inbound and outbound

```bash
curl -sS https://api.leadarc.io/v1/workspaces/airpay/leads/lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny/messages?limit=25 \
  -H "Authorization: Bearer $LEADARC_API_KEY"
```

**Query parameters.** Anything else is `400 unsupported_filter`.

`limit` `order`

**Errors from this endpoint**, on top of the [common ones](errors.html): `not_found`.

```json
{
  "object": "list",
  "data": [
    {
      "object": "message",
      "id": "msg_Fb_7gAhPO7Owf7ISszHVGCoxxATH-3wQR9J0kt",
      "lead_id": "lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny",
      "kind": "campaign_send",
      "direction": "outbound",
      "reply_id": null,
      "subject": "quick question about your billing flow",
      "from_name": "Sara Whitfield",
      "from_email": "sara@airpay-outreach.com",
      "to_email": "m.adams@northgatecare.com",
      "body_html": "<p>Hi Mary,</p><p>…</p>",
      "body_text": "Hi Mary,\n\n…",
      "sent_at": "2026-07-24T08:02:00Z",
      "attachments": []
    },
    {
      "object": "message",
      "id": "msg_Fb_7gAhPO7Owf7ISszHVGCoxxATH-3wQR9J0kt",
      "lead_id": "lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny",
      "kind": "reply",
      "direction": "inbound",
      "reply_id": "rep_Dk9dw7dVABGNJm9HK9onvZKXLneRRpj9MIB-C3",
      "subject": "Re: quick question about your billing flow",
      "from_name": "Mary Adams",
      "from_email": "m.adams@northgatecare.com",
      "to_email": null,
      "body_html": "<div>Sure, happy to take a look…</div>",
      "body_text": "Sure, happy to take a look…",
      "sent_at": "2026-07-27T09:14:22Z",
      "attachments": [
        { "object": "attachment", "id": "att_2VYX91SDoS_9OOUs6aLDqDkMMtFc1YOf3ZzDX-", "filename": "billing-questions.pdf" }
      ]
    }
  ],
  "meta": {
    "truncated": false,
    "sibling_cap_reached": false,
    "cache_age_seconds": 42
  },
  "has_more": false,
  "next_cursor": null
}
```
**Six facts a caller must have, all of them consequences of how the app builds a thread.**

1. **`direction` and `sent_at` are computed by `threadMessageIsFromLead` and `threadMessageTimestamp` from the app. The API must not reimplement either.** That file exists specifically to kill two bugs. `direction` is folder-and-type first, because matching the sender against the stored lead email "mislabels" a lead who replies from a different address (the file names this the "Us/Lead is wrong" bug). `sent_at` uses `created_at` for outbound and `date_received` for inbound, verified against live EmailBison on 2026-06-17, because EmailBison's `date_received` on an outgoing message is a delivery stamp 6 to 23 minutes later, which sorted a quick reply after the message it answered.

2. **`direction` is `best-effort`.** When EmailBison ships neither a `folder` nor a `type` tag, the helper falls back to the sender-email match, which is the case it warns about. The field is non-null because a timeline needs a value, and the determinism class is the honest signal.

3. **`body_text` is `derived`, not a stored column.** `EbThreadMessage` in the app is exactly `{ id, date_received?, created_at?, from_email_address?, folder?, type? }`, and `ThreadMsg` adds `subject`, `html_body`, `from_name`, `primary_to_email_address`, `folder`, `type`, `attachments`. **There is no plaintext body anywhere in the read path.** `body_text` is produced by `htmlToText` from the app, which collapses tags and is explicitly not a sanitizer. It is good enough for an LLM to read and wrong for rendering.

4. **`to_email` is usually null on inbound messages.** It comes from `ThreadMsg.primary_to_email_address`, which the app only populates on synthesized campaign sends (`sentEmailToMessage` in the app). Real replies from the conversation-thread endpoint do not carry it.

5. **The message set is capped and not fully deterministic.** `MAX_SIBLINGS = 12` bounds the EmailBison fanout for a chatty lead, so a very long history is truncated. A slow or failed sibling thread contributes nothing to that build. `TTL_MS = 10 * 60_000` serves stale for up to ten minutes while refreshing in the background, so this endpoint can lag the `last_message_at` that `/leads` just reported. The `meta` block reports `truncated`, `sibling_cap_reached`, and `cache_age_seconds` so a caller can tell.

6. **`attachments` carries a filename and nothing else.** `ReplyAttachment` in the app is `{ id: number; uuid?: string; file_name: string; download_url: string }`. There is no size and no content type, because EmailBison does not send them. `download_url` is a signed EmailBison link whose signature covers the exact path, so it is withheld: handing it out would leak a capability URL into a third-party system. Downloads need a proxied streaming endpoint with its own auth, which is v3.

## GET /v1/workspaces/{ws}/leads/{lead}/assignees

`leads:read` · read bucket · returns list of member

Who owns this lead

```bash
curl -sS https://api.leadarc.io/v1/workspaces/airpay/leads/lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny/assignees \
  -H "Authorization: Bearer $LEADARC_API_KEY"
```

**Errors from this endpoint**, on top of the [common ones](errors.html): `invalid_request`, `not_found`, [`unprocessable`](errors.html#status-422), `unsupported_filter`.

Scopes: [`leads:read`](scopes.html#all) / [`leads:write`](scopes.html#all).

`PUT` body replaces the whole set:
```json
{ "member_ids": ["usr_zaDRRpdu8EPZSU3i89lvZgKPkVQo4uBFA_KOhm", "usr_zaDRRpdu8EPZSU3i89lvZgKPkVQo4uBFA_KOhm"] }
```
Response:
```json
{
  "object": "list",
  "data": [ /* member objects, with assignment_source and assigned_at */ ],
  "has_more": false,
  "next_cursor": null,
  "effects": {
    "assignees_added": ["usr_zaDRRpdu8EPZSU3i89lvZgKPkVQo4uBFA_KOhm"],
    "assignees_removed": [],
    "provider_writes": []
  }
}
```
Every member id is validated against `assignableUsersForUi(client.id)` before it is written. The internal route does this and the reason is in its own header: so a non-admin can never persist an admin as a rule's assignee. Sending a member who does not belong to this workspace is `422 unprocessable`, never a silent write. That check is the thing most likely to be forgotten, because it is invisible in the happy path.

Manual writes set `lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny = "manual"`.

## PUT /v1/workspaces/{ws}/leads/{lead}/assignees

`leads:write` · write bucket · returns list of member

Replace the assignee set

```bash
curl -sS https://api.leadarc.io/v1/workspaces/airpay/leads/lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny/assignees \
  -X PUT \
  -H "Authorization: Bearer $LEADARC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"member_ids":"..."}'
```

**Body fields.** An unknown field is rejected.

`member_ids`

**Errors from this endpoint**, on top of the [common ones](errors.html): `invalid_request`, `not_found`, `unprocessable`, `unsupported_filter`.

Scopes: `leads:read` / `leads:write`.

`PUT` body replaces the whole set:
```json
{ "member_ids": ["usr_zaDRRpdu8EPZSU3i89lvZgKPkVQo4uBFA_KOhm", "usr_zaDRRpdu8EPZSU3i89lvZgKPkVQo4uBFA_KOhm"] }
```
Response:
```json
{
  "object": "list",
  "data": [ /* member objects, with assignment_source and assigned_at */ ],
  "has_more": false,
  "next_cursor": null,
  "effects": {
    "assignees_added": ["usr_zaDRRpdu8EPZSU3i89lvZgKPkVQo4uBFA_KOhm"],
    "assignees_removed": [],
    "provider_writes": []
  }
}
```
Every member id is validated against `assignableUsersForUi(client.id)` before it is written. The internal route does this and the reason is in its own header: so a non-admin can never persist an admin as a rule's assignee. Sending a member who does not belong to this workspace is `422 unprocessable`, never a silent write. That check is the thing most likely to be forgotten, because it is invisible in the happy path.

Manual writes set `lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny = "manual"`.

## POST /v1/workspaces/{ws}/leads/{lead}/status

`leads:write` · write bucket · returns lead

Set the SDR tag

```bash
curl -sS https://api.leadarc.io/v1/workspaces/airpay/leads/lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny/status \
  -X POST \
  -H "Authorization: Bearer $LEADARC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status":"..."}'
```

**Body fields.** An unknown field is rejected.

`status`

**Errors from this endpoint**, on top of the [common ones](errors.html): `invalid_request`, `not_found`, `unsupported_filter`.

```json
{ "status": "not_interested" }
```
Accepts `interested`, `not_interested`, `automated`, `unsubscribed`, `untagged`. `dnc` is accepted as an alias for `unsubscribed`.

**Read this before using it.** Setting `not_interested` or `unsubscribed` **closes every open task on this lead**, across every one of the lead's reply rows. `clearLeadTasks` in the app does it, and it is wired into four routes: `mark-not-interested`, `unsubscribe`, `blacklist-email`, `blacklist-domain`. It is reversible in the sense that it sets `completed_at` and never deletes, but nothing puts those tasks back.

`unsubscribed` is **local only in v1**. It writes the tag and clears tasks. It does **not** call EmailBison to unsubscribe the lead. The EmailBison write lives behind `leads:suppress` in v3, because it changes what the sending platform will do.

Response, with the effects made visible:
```json
{
  "object": "lead",
  "id": "lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny",
  "status": "not_interested",
  "last_action_at": "2026-07-27T10:31:04Z",
  "effects": {
    "replies_updated": ["rep_Dk9dw7dVABGNJm9HK9onvZKXLneRRpj9MIB-C3", "rep_Dk9dw7dVABGNJm9HK9onvZKXLneRRpj9MIB-C3"],
    "tasks_completed": ["tsk_i-_uD-R7uXULK8lecjVGbgq71OpftiUkqXfdvx", "tsk_i-_uD-R7uXULK8lecjVGbgq71OpftiUkqXfdvx"],
    "lead_status_set": "not_interested",
    "provider_writes": []
  }
}
```
Writes an `action_log` row with the matching action (`NOT_INTERESTED`, `INTERESTED`, `AUTOMATED`, `DNC`), `user_id` = the key's owner, and `api_key_id` = the key.

## POST /v1/workspaces/{ws}/leads/{lead}/read

`leads:write` · write bucket · returns lead

Mark read for the key's owner

```bash
curl -sS https://api.leadarc.io/v1/workspaces/airpay/leads/lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny/read \
  -X POST \
  -H "Authorization: Bearer $LEADARC_API_KEY"
```

**These change two things, and one of them is team-wide.** So a key marking a lead read also stamps the global receipt for the workspace.

The API keeps both writes, because dropping the second would leave the app and the API disagreeing about the same lead. The `effects` object makes it visible:
```json
{
  "object": "lead",
  "id": "lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny",
  "unread": false,
  "effects": {
    "read_receipts_written": ["usr_zaDRRpdu8EPZSU3i89lvZgKPkVQo4uBFA_KOhm"],
    "replies_updated": ["rep_Dk9dw7dVABGNJm9HK9onvZKXLneRRpj9MIB-C3", "rep_Dk9dw7dVABGNJm9HK9onvZKXLneRRpj9MIB-C3"],
    "provider_writes": []
  }
}
```
Idempotent by `unique("lead_reads_unique").on(clientId, ebLeadId, userId)` plus the `greatest()` guard, which means only a strictly newer inbound reply can re-flag it.

## POST /v1/workspaces/{ws}/leads/{lead}/unread

`leads:write` · write bucket · returns lead

Clear the read receipt

```bash
curl -sS https://api.leadarc.io/v1/workspaces/airpay/leads/lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny/unread \
  -X POST \
  -H "Authorization: Bearer $LEADARC_API_KEY"
```

**These change two things, and one of them is team-wide.** So a key marking a lead read also stamps the global receipt for the workspace.

The API keeps both writes, because dropping the second would leave the app and the API disagreeing about the same lead. The `effects` object makes it visible:
```json
{
  "object": "lead",
  "id": "lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny",
  "unread": false,
  "effects": {
    "read_receipts_written": ["usr_zaDRRpdu8EPZSU3i89lvZgKPkVQo4uBFA_KOhm"],
    "replies_updated": ["rep_Dk9dw7dVABGNJm9HK9onvZKXLneRRpj9MIB-C3", "rep_Dk9dw7dVABGNJm9HK9onvZKXLneRRpj9MIB-C3"],
    "provider_writes": []
  }
}
```
Idempotent by `unique("lead_reads_unique").on(clientId, ebLeadId, userId)` plus the `greatest()` guard, which means only a strictly newer inbound reply can re-flag it.

## POST /v1/workspaces/{ws}/leads/{lead}/responded

`leads:write` · write bucket · returns lead

Team-wide "handled", drops it from every rep's Daily

```bash
curl -sS https://api.leadarc.io/v1/workspaces/airpay/leads/lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny/responded \
  -X POST \
  -H "Authorization: Bearer $LEADARC_API_KEY"
```

**This is a team-wide state change, not a per-user one.** The route's own comment says it: "Unlike mark-read (per-user), this is GLOBAL: one marker per lead clears it from every rep's Daily." One row per lead in `lead_responses`, so whoever marks it clears it for the whole team.

An agent that calls this on every lead it polls will empty every human SDR's Daily worklist. `responded_at` is stamped `greatest(now, last_message_at)`, so a strictly newer inbound reply re-arms it and the lead comes back.
```json
{
  "object": "lead",
  "id": "lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny",
  "responded": true,
  "effects": {
    "team_markers_written": ["responded"],
    "provider_writes": []
  }
}
```
Idempotent by `unique("lead_responses_unique").on(clientId, ebLeadId)`. `responded_by_user_id` records the key's owner for audit only; the marker itself is team-wide.

## POST /v1/workspaces/{ws}/leads/{lead}/booked

`leads:write` · write bucket · returns lead

Mark booked, local only

```bash
curl -sS https://api.leadarc.io/v1/workspaces/airpay/leads/lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny/booked \
  -X POST \
  -H "Authorization: Bearer $LEADARC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"create_meeting":"..."}'
```

**Body fields.** An unknown field is rejected.

`create_meeting`

Both are local-only lifecycle states. They hide the lead from the working inbox and **deliberately leave the EmailBison tag untouched**, so EmailBison reporting stays accurate. That is why they are columns (`booked_at`, `disqualified_at`) and not tags.

Both **close the lead's open tasks**. `disqualify` and `mark-booked` inline the same `UPDATE tasks SET completed_at` that `clearLeadTasks` does. The route is the truth. The `effects` object reports what actually happened, so the API cannot inherit that stale comment.

`booked` body: `{ "create_meeting": false }`. Default `false`, because creating a meeting record is a bigger action than setting a flag.

`disqualified` body: `{ "reengage_approved": true }`.

---

## POST /v1/workspaces/{ws}/leads/{lead}/disqualified

`leads:write` · write bucket · returns lead

Mark disqualified, local only

```bash
curl -sS https://api.leadarc.io/v1/workspaces/airpay/leads/lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny/disqualified \
  -X POST \
  -H "Authorization: Bearer $LEADARC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"reengage_approved":"..."}'
```

**Body fields.** An unknown field is rejected.

`reengage_approved`

Both are local-only lifecycle states. They hide the lead from the working inbox and **deliberately leave the EmailBison tag untouched**, so EmailBison reporting stays accurate. That is why they are columns (`booked_at`, `disqualified_at`) and not tags.

Both **close the lead's open tasks**. `disqualify` and `mark-booked` inline the same `UPDATE tasks SET completed_at` that `clearLeadTasks` does. The route is the truth. The `effects` object reports what actually happened, so the API cannot inherit that stale comment.

`booked` body: `{ "create_meeting": false }`. Default `false`, because creating a meeting record is a bigger action than setting a flag.

`disqualified` body: `{ "reengage_approved": true }`.

---

---

Base URL `https://api.leadarc.io/v1`. Generated from the API source.
