Markdown for agents
This page as plain markdown, ready to paste into an LLM.
Leads
The feed everything else hangs off, the conversation behind a lead, and the writes that move one along.
Filtered, paginated lead feed, one row per lead
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.
GET /v1/workspaces/airpay/leads?status=interested&has_open_task=false&limit=25{
"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.
campaign_idandcampaign_nameare 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.
unread,has_open_task, andnext_due_atarekey-scoped. They are computed for the key's owner. Two keys owned by two different people return different values for the same lead.
statusisbest-effortat the lead level. A tag an agent just wrote can change with no caller action if a sibling reply is tagged.
One lead with rollups
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.
Every indexed inbound reply from this lead
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.
{
"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.
Full merged conversation, inbound and outbound
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.
{
"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.
directionandsent_atare computed bythreadMessageIsFromLeadandthreadMessageTimestampfrom the app. The API must not reimplement either. That file exists specifically to kill two bugs.directionis 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_atusescreated_atfor outbound anddate_receivedfor inbound, verified against live EmailBison on 2026-06-17, because EmailBison'sdate_receivedon an outgoing message is a delivery stamp 6 to 23 minutes later, which sorted a quick reply after the message it answered.
directionisbest-effort. When EmailBison ships neither afoldernor atypetag, 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.
body_textisderived, not a stored column.EbThreadMessagein the app is exactly{ id, date_received?, created_at?, from_email_address?, folder?, type? }, andThreadMsgaddssubject,html_body,from_name,primary_to_email_address,folder,type,attachments. There is no plaintext body anywhere in the read path.body_textis produced byhtmlToTextfrom the app, which collapses tags and is explicitly not a sanitizer. It is good enough for an LLM to read and wrong for rendering.
to_emailis usually null on inbound messages. It comes fromThreadMsg.primary_to_email_address, which the app only populates on synthesized campaign sends (sentEmailToMessagein the app). Real replies from the conversation-thread endpoint do not carry it.
- The message set is capped and not fully deterministic.
MAX_SIBLINGS = 12bounds 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_000serves stale for up to ten minutes while refreshing in the background, so this endpoint can lag thelast_message_atthat/leadsjust reported. Themetablock reportstruncated,sibling_cap_reached, andcache_age_secondsso a caller can tell.
attachmentscarries a filename and nothing else.ReplyAttachmentin 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_urlis 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.
Who owns this lead
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:
{ "member_ids": ["usr_zaDRRpdu8EPZSU3i89lvZgKPkVQo4uBFA_KOhm", "usr_zaDRRpdu8EPZSU3i89lvZgKPkVQo4uBFA_KOhm"] }Response:
{
"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".
Replace the assignee set
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:
{ "member_ids": ["usr_zaDRRpdu8EPZSU3i89lvZgKPkVQo4uBFA_KOhm", "usr_zaDRRpdu8EPZSU3i89lvZgKPkVQo4uBFA_KOhm"] }Response:
{
"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".
Set the SDR tag
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.
{ "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:
{
"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.
Mark read for the key's owner
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:
{
"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.
Clear the read receipt
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:
{
"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.
Team-wide "handled", drops it from every rep's Daily
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.
{
"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.
Mark booked, local only
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 }.
Mark disqualified, local only
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 }.