LeadArc Docsv1

Markdown for agents

This page as plain markdown, ready to paste into an LLM.

.md

Leads

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

leads:readread bucketreturns 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.

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: invalid_cursor, invalid_request, not_found, unsupported_filter.

text
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.
  1. 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.
  1. 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.
leads:readread bucketreturns 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: invalid_request, not_found, unsupported_filter.

leads:readread bucketreturns 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: 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 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.

threads:readread bucketreturns 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: 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.
  1. 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.
  1. 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.
  1. 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.
  1. 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.
  1. 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.
leads:readread bucketreturns 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: 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".

leads:writewrite bucketreturns 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: 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".

leads:writewrite bucketreturns 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: 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.

leads:writewrite bucketreturns 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.

leads:writewrite bucketreturns 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.

leads:writewrite bucketreturns 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.

leads:writewrite bucketreturns 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 }.


leads:writewrite bucketreturns 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.