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 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.
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,
"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.
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,priority,has_open_task, andnext_due_atarekey-scoped. They are computed for the key's owner. Two keys owned by two different people can 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 refresh
Errors from this endpoint, on top of the common ones: invalid_request, 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 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.
Paginated manual lead-tag roster
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.
Manual tags applied to one lead
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.
Replace the lead's full manual-tag set
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.
Set or clear the key owner's priority star for up to 100 leads
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.
{
"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.
{
"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.
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".
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"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 }.
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.
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 }.
At-most-once reply through the lead's sending platform
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:
{
"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.
{
"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:
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.
Blacklist the email or the whole domain
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.
Paid phone lookup
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.
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.
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.
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.
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.
curl -sS https://api.leadarc.io/v1/workspaces/airpay/leads/lead_BP2vnPmKyttNP2OlH_mWL78t1o6nBVC6oVbHny/reply_context \
-H "Authorization: Bearer $LEADARC_API_KEY"