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 lead_tag_id email q has_open_task task_due_before meeting_outcome direction updated_since updated_before unread responded priority

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,
    "priority": 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, priority, 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 can 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 refresh

Errors from this endpoint, on top of the common ones: invalid_request, 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 use the authenticated endpoint described below.

Attachment download: GET /api/v1/workspaces/{ws}/leads/{lead}/attachments/{attachment}/download. Requires threads:read and access to the workspace. Both opaque IDs must belong to that workspace. The message must belong to the named lead's thread, and the file must belong to that message. The server renews EmailBison reply links before fetching bytes. Received and sent replies are supported. The endpoint returns application/octet-stream with Content-Disposition: attachment and Cache-Control: no-store. Use the filename from the message's attachment metadata for a local preview. The response is binary, not a JSON envelope. Errors keep the normal JSON error shape.

Downloads are bounded to 25 MB and a 30-second provider fetch timeout. Redirects are limited to the configured EmailBison origin and its verified R2 attachment bucket. Signed provider links and API tokens are never sent to the caller or to other hosts.

leads:readread bucket

Paginated manual lead-tag roster

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

Query parameters. Anything else is 400 unsupported_filter.

cursor limit

Errors from this endpoint, on top of the common ones: invalid_cursor, unsupported_filter.

leads:readread bucket

Manual tags applied to one lead

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

Errors from this endpoint, on top of the common ones: invalid_request, not_found, unprocessable, unsupported_filter.

leads:writewrite bucket

Replace the lead's full manual-tag set

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

Body fields. An unknown field is rejected.

tag_ids

Errors from this endpoint, on top of the common ones: invalid_request, not_found, unprocessable, unsupported_filter.

leads:writewrite bucket

Set or clear the key owner's priority star for up to 100 leads

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

Body fields. An unknown field is rejected.

lead_ids priority

Errors from this endpoint, on top of the common ones: invalid_request, not_found, unsupported_filter.

json
{
  "lead_ids": ["lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny", "lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny"],
  "priority": true
}

lead_ids takes 1 to 100 opaque lead ids. Repeated ids are removed. Every id must belong to the path workspace and every lead must still exist. Validation and mutation share one transaction. One missing, deleted, malformed, or foreign lead rejects the whole request before any priority changes.

json
{
  "object": "lead_priority_update",
  "workspace": "airpay",
  "priority": true,
  "lead_ids": ["lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny", "lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny"],
  "matched_count": 2,
  "updated_count": 2,
  "effects": {
    "lead_priorities_changed": ["lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny", "lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny"],
    "lead_priority_set": true,
    "provider_writes": []
  }
}

matched_count is the number of unique, validated request ids. updated_count is the number of rows that changed. The response keeps every matched id in lead_ids, so an idempotent retry is explicit: it returns the same ids and matched_count, with updated_count: 0 and an empty effects.lead_priorities_changed array.

Priority is per user. Another key owner can prioritize the same lead without changing this owner's star. Sending priority: false deletes only the current owner's rows. The lead list supports ?priority=true and ?priority=false.

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

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"

Errors from this endpoint, on top of the common ones: invalid_request, not_found.

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.

Both calls require Idempotency-Key. Lead state, task cleanup, audit, any new meeting row, and the stored replay response commit in one transaction.

booked body: { "create_meeting": false }. This is the native default because Cal sync owns real bookings; Mark Booked must not invent a meeting or ask the rep to convert time zones. true matches the desktop action and creates one scheduled_meetings row with source: "mark_booked". It may also carry meeting_date, meeting_time, meeting_timezone, and internal_notes. Date and time must be supplied together. Idempotency prevents a retry from creating a second row.

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.

Both calls require Idempotency-Key. Lead state, task cleanup, audit, any new meeting row, and the stored replay response commit in one transaction.

booked body: { "create_meeting": false }. This is the native default because Cal sync owns real bookings; Mark Booked must not invent a meeting or ask the rep to convert time zones. true matches the desktop action and creates one scheduled_meetings row with source: "mark_booked". It may also carry meeting_date, meeting_time, meeting_timezone, and internal_notes. Date and time must be supplied together. Idempotency prevents a retry from creating a second row.

disqualified body: { "reengage_approved": true }.


replies:sendupstream bucket

At-most-once reply through the lead's sending platform

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

Body fields. An unknown field is rejected.

attachments subject

Errors from this endpoint, on top of the common ones: not_found, reply_delivery_unknown, unprocessable, unresolved_variables.

The JSON shape matches the native Swift client. Send exactly one body field:

json
{
  "body_text": "Thanks, Thursday at 2 works.",
  "subject": "Re: Thursday"
}

body_html may replace body_text for the composer's HTML mode. subject is optional. Files use multipart/form-data with the same text fields and one repeated attachments file part per attachment. A reply accepts at most 10 files, 10 MB per file, and 25 MB combined. The multipart wire body is capped at 26 MB. JSON is capped at 256 KB, the reply body at 100,000 characters, and the subject at 998 characters. Connector-owned threads currently reject attachments before claiming the key because their send APIs cannot carry them. EmailBison and demo threads accept them.

Every remaining mustache placeholder in the body or subject, including {{first_name}}, {{asset:loom}}, and {{ai:PAIN}}, returns 422 unprocessable before a claim or provider call. The path workspace and opaque lead id are re-scoped before egress. The public shape has no cc, bcc, or recipient override. Provider reply-all logic keeps the lead as the recipient.

json
{
  "object": "send_reply_result",
  "ok": true,
  "message": null,
  "effects": {
    "replies_updated": ["rep_Dk9dw7dVABGNJm9HK9onvZKXLneRRpj9MIB-C3"],
    "provider_writes": ["emailbison:send_reply"]
  }
}

message is null because the provider response is not a stable public message object. The native app refreshes the thread after confirmation. Demo sends return an empty provider_writes array. Connector sends name the real owner, such as instantly:send_reply.

The key claim, provider ordering, and replay rules are in the Idempotency section. A confirmed send updates the representative reply to direction sent, writes one REPLY action with user_id set to the key owner and api_key_id set to the key, and completes the stored response in one database transaction. A failure after provider delivery leaves the claim pending and returns 503 reply_delivery_unknown; it never rolls the claim back into another automatic send.

POST /v1/workspaces/{ws}/bookings (v2, bookings:write). Re-checks the slot immediately before writing so a stale slot returns a clean "pick another time" instead of a 409. Widest failure surface in the app: a Google Calendar write, a confirmation email, scheduled reminders through booking_email_log, and the scheduled_meetings_host_slot_uniq partial unique index as the double-booking backstop.

GET /v1/events (v2, events:read). Cross-workspace by default, order=asc default, created_since, wildcard type filter (lead.*), 90-day retention. The documented sync loop is order=asc plus created_since, backed by a purpose-built (client_id, created_at, id) index on the new api_events table. This is the one collection that is not nested, because a poller wants one loop and not N.

Webhook endpoints (v2, webhooks:write). https only. An SSRF blocklist checked at registration and again at every delivery, because a hostname can be re-pointed after registration: reject loopback, 169.254.0.0/16, and every RFC1918 range. The signing secret appears only in the create response and the rotate response, and is stored AES-256-GCM encrypted through the app because we must read it back to sign each delivery. api_webhook_deliveries stores the response status code and no response body. Design 1 proposed storing 2 KB of it for debugging; in the SSRF scenario the endpoint's owner is the attacker, and 2 KB of an instance-metadata response is plenty. Auto-disable after 20 consecutive failures with an email to the creator. Deliveries pruned after 7 days.

Webhook subscriptions are filtered by the key's scopes. A key holding only webhooks:write cannot subscribe to lead.* and receive lead payloads. Without that filter, webhooks:write would be a continuous exfiltration channel that bypasses leads:read entirely, and GET /v1/webhook_deliveries would then let the same key read back what was delivered. The same rule applies to GET /v1/events: an event row is only visible if the key holds the read scope for the object it carries.

POST /v1/workspaces/{ws}/leads/{lead}/suppress (v3, leads:suppress). The most dangerous endpoint in the whole design. mode: "email" or mode: "domain". Domain mode blacklists an entire domain in EmailBison for that workspace, permanently from our side, with no un-suppress path in this API. On a franchise group or a hospital network that removes hundreds of reachable prospects. Guards: its own scope, never in a preset, styled red in the create-key dialog; ?dry_run=true returning affected_leads_estimate only; a required confirm_domain field that must match the resolved domain exactly, which is the "type the name to delete" pattern applied to an API; 20 per hour; and a lead.suppressed event so a human is told.

PUT /v1/workspaces/{ws}/assignment_settings and /assignment_rules (v3, assignments:write). Both need one correction written into these pages before anyone builds against them. weights is keyed by member id, and a member with no entry is excluded from the rotation, not defaulted to 1. the app:

ts
const included = [...explicitOk].filter((id) => (weights[String(id)] ?? 0) > 0);
const pool = new Map<number, number>(
  included.length ? included.map((id) => [id, weights[String(id)]]) : memberIds.map((id) => [id, 1]),
);

Equal weight for everyone applies only when the map is entirely empty. So weights: { "usr_x": 2 } does not give one rep double share, it routes 100% of interested leads to that one rep and silently stops everyone else. Also, excluded_member_ids is read only when method === "all" and is ignored for round_robin and one. assignment_settings.rr_state is never exposed and never accepted: it is the engine's round-robin credit ledger, keyed by rotation ("default" or "tags:12,15"), and letting a caller write it corrupts fairness for everyone. assignment_rules is a whole-set PUT and never a per-row PATCH, because the internal save path deletes and re-inserts every row, so assignment_rules.id is not a stable identifier.


leads:suppressupstream bucket

Blacklist the email or the whole domain

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

Body fields. An unknown field is rejected.

mode confirm_domain

Errors from this endpoint, on top of the common ones: invalid_request, status_change_unknown, unprocessable, upstream_error.

leads:enrichupstream bucket

Paid phone lookup

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

Body fields. An unknown field is rejected.

force

Errors from this endpoint, on top of the common ones: conflict, internal_error, rate_limited, unprocessable, upstream_error.

notes:readread bucket
bash
curl -sS https://api.leadarc.io/v1/workspaces/airpay/leads/lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny/activities?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, invalid_request, not_found, unprocessable, unsupported_filter.

notes:writewrite bucket
bash
curl -sS https://api.leadarc.io/v1/workspaces/airpay/leads/lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny/activities \
  -X POST \
  -H "Authorization: Bearer $LEADARC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"kind":"...","body":"...","outcome":"..."}'

Body fields. An unknown field is rejected.

kind body outcome occurred_at

Errors from this endpoint, on top of the common ones: invalid_cursor, invalid_request, not_found, unprocessable, unsupported_filter.

threads:readread bucket
bash
curl -sS https://api.leadarc.io/v1/workspaces/airpay/leads/lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny/attachments/{attachment}/download \
  -H "Authorization: Bearer $LEADARC_API_KEY"

Errors from this endpoint, on top of the common ones: not_found, upstream_error.

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

Errors from this endpoint, on top of the common ones: not_found, upstream_error.

replies:sendread bucket
bash
curl -sS https://api.leadarc.io/v1/workspaces/airpay/leads/lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny/reply_context \
  -H "Authorization: Bearer $LEADARC_API_KEY"

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