LeadArc Docsv1

Markdown for agents

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

.md

Object schemas

Every field of every object, read from the serializers the server runs.

Every object the API returns. A field that is not in these tables is not on the wire.

workspace member lead reply attachment message note task_sequence task meeting event_type event_type_host event_type_form_field slot availability template template_folder asset sequence sequence_step enrollment campaign_tag campaign rep_stat speed_bucket insights_summary campaign_step campaign_insight daily_point

Reading these tables

How much to trust a field

Every field in every object schema carries a determinism class. This is the single change that keeps a caller from writing code that looks right and is quietly wrong.

ClassMeaning
stableA real column. The same for every caller, forever.
derivedComputed from stable columns. The same for every caller.
key-scopedDepends on which key asked, because it depends on the key's owner.
best-effortA heuristic or a provider-dependent value with documented failure modes.
upstreamProxied live from EmailBison. May be stale, capped, or truncated.

Three fields on lead are key-scoped and it matters. has_open_task, next_due_at, and unread all come from rollups that filter on the viewer. the app:

ts
eq(sql`coalesce(${schema.tasks.assignedToId}, ${schema.tasks.userId})`, opts.viewerId),

Two keys owned by two different people get different answers for the same lead, and neither answer is "does this lead have open work". Marking them key-scoped in the field table forces a caller to reason about it. The alternative, silently returning one person's view as if it were the truth, is how a report ends up wrong.

Never returned

These columns are left out of every response, expansion and error message.

TableWithheld columns
clientsid eb_token eb_team_id docuseal_api_key airtable_record_id slack_internal_channel_id slack_client_channel_id gdrive_link cal_com_link no_calling public_booking_enabled feature_crm feature_calling
userspassword_hash password_set_at fathom_api_key google_sub signature twilio_phone_number twilio_phone_sid twilio_numbers crm_enabled inbox_enabled calling_enabled email_notify_enabled onboarded_at install_prompt_dismissed_at deactivated_at
responses_indexnotified_at last_read_at disqualified_by booked_by
lead_notesclient_id response_id
tasksdeleted_at email_body linkedin_text email_template_id enrollment_id sequence_step_id company_id opportunity_id
scheduled_meetingsmeet_link google_event_id google_calendar_id google_event_html_link host_notified_at response_id eb_lead_id company_id contact_id opportunity_id internal_notes
event_typesexternal_id exclude_from_insights created_by_user_id
event_type_email_settings*
reply_templatesai_vars.instructions group
followup_sequencesdeleted_at
followup_enrollmentsclient_id
thread_cachepayload updated_at
reply_attachmentsdownload_url uuid
api_keyssecret_hash key_id created_by_id revoked_by_id last_used_ip_prefix request_count
enrichment_eventsphone

The objects

workspace

id is the SLUG, not clients.id. The slug is already the public handle everywhere in this product (it is in every internal URL), it is stable, and it reads well in a caller's code. clients.id is a serial that appears inside signed object ids and never on its own.

synced_at is published under its real name. It is when the sync-eb-workspaces cron last reconciled the workspace against EmailBison, so calling it created_at would invent a meaning for a value that moves every six hours.

counts and sdr_notes are present only when the caller asked for them via ?include=.

FieldTypeNotes
object"workspace"
idstring
namestring
domainstring | null
logo_urlstring | null
activeboolean
featuresRecord<string, boolean>
granted_scopesstring[]
synced_atstring | null
counts (optional)WorkspaceCounts
sdr_notes (optional)string | null

member

email is users.google_email: the table has no email column, the sign-in identity is the Google address.

role goes through normalizeRole, which returns only admin or user. Seven legacy values still sit in the column (internal_sdr, client_sdr, client_viewer, client_ae, sdr, client, client_sdr_airpay) and all fold to user, so a caller never has to know they existed.

active is the boolean form of deactivated_at. The timestamp itself stays private: when a person was offboarded is nobody's business outside the agency.

assignment is set only on GET.../leads/{lead}/assignees, where the two extra fields describe the assignment rather than the person.

FieldTypeNotes
object"member"
idstring
namestring | null
emailstring
avatar_urlstring | null
role"admin" | "user"
timezonestring | null
activeboolean
assignableboolean
created_atstring | null
assignment_source (optional)"auto" | "manual"
assigned_at (optional)string | null

lead

A lead is a group of reply rows, collapsed to its newest.

responded, unread, has_open_task and next_due_at are resolved for the key's owner, so two keys held by two people report different values for the same lead. responded is team-wide instead.

campaign_id and campaign_name are null far more often than a caller expects. A background job fills them in within 15 minutes, and historical rows stay null with no backfill.

FieldTypeNotes
object"lead"
idstring
workspacestring
emailstring
first_namestring | null
last_namestring | null
company_namestring | null
titlestring | null
statusLeadStatus
classificationstring | null
stateLeadState
subjectstring | null
body_previewstring | null
latest_reply_idstring
last_message_atstring | null
last_message_directionstring
last_action_atstring | null
booked_atstring | null
disqualified_atstring | null
meeting_outcomestring | null
meeting_outcome_atstring | null
respondedboolean
unreadboolean
campaign_idstring | null
campaign_namestring | null
assignee_idsstring[]
has_open_taskboolean
next_due_atstring | null
indexed_atstring | null
assignees (optional)SerializedMember[]Expansions, each present only when requested.
campaign (optional)SerializedCampaign | null
open_tasks (optional)SerializedTask[]
notes (optional)SerializedNote[]
meetings (optional)SerializedMeeting[]
sdr_notes (optional)string | null

reply

One indexed inbound email, the atomic unit responses_index is keyed on. The full conversation lives at /messages.

status is the raw per-reply last_action, lowercased the same way as a lead's but nullable here, because at the reply level "no tag" is genuinely absent rather than the untagged lifecycle state.

FieldTypeNotes
object"reply"
idstring
lead_idstring
workspacestring
emailstring
subjectstring | null
body_previewstring | null
statusstring | null
classificationstring | null
directionstring
received_atstring | null
campaign_idstring | null
campaign_namestring | null

attachment

A filename and an opaque id. That is the whole object.

ReplyAttachment is { id, uuid?, file_name, download_url }. There is no size and no content type in EmailBison's payload, so neither is invented. download_url is withheld: it is a signed EmailBison link whose signature covers the exact path, which makes it a bearer capability, and handing one to a third-party integration gives that integration the file. Downloading needs a proxied endpoint with its own auth, which is v3.

FieldTypeNotes
object"attachment"
idstring
filenamestring

message

One message in a lead's merged timeline.

kind comes from the sign of the raw id. buildLeadThreadMessages synthesizes a campaign send as -e.id so it cannot collide with a real reply id during dedupe, which makes the sign the only marker of provenance. The negative integer itself is never surfaced.

direction and sent_at are delegated to thread-direction.ts and must not be reimplemented. That module exists to kill two live bugs: matching the sender against the stored lead email mislabels a lead who replies from a different address, and EmailBison's date_received on an OUTGOING message is a delivery stamp 6 to 23 minutes late, which sorted a quick reply before the message it answered.

body_text is derived. There is no plaintext column anywhere in the read path: EbThreadMessage has no text_body, so this is htmlToText(html_body), which collapses tags and is explicitly not a sanitizer. Good enough for a model to read, wrong for rendering.

to_email is usually null on a real reply. It comes from primary_to_email_address, which only the synthesized campaign sends carry.

FieldTypeNotes
object"message"
idstring
lead_idstring
kind"reply" | "campaign_send"
direction"inbound" | "outbound"
reply_idstring | null
subjectstring | null
from_namestring | null
from_emailstring | null
to_emailstring | null
body_htmlstring | null
body_textstring | null
sent_atstring | null
attachmentsSerializedAttachment[]

note

FieldTypeNotes
object"note"
idstring
workspacestring
lead_idstring
reply_idstring | null
bodystring | null
author_idstring | null
created_atstring | null
updated_atstring | null

task_sequence

FieldTypeNotes
idstring
namestring | null
step_indexnumber | null

task

Four columns this object claims do not exist on tasks, and each absence is load-bearing.

There is no client_id. The tenant is derived through response_id -> responses_index.client_id, and every query adds isNotNull(response_id) because response_id is nullable so a task can hang off a dead CRM company_id instead. Those rows have no lead, no reply and no derivable workspace, so they are invisible to the API. That is also why reply_id here is non-null.

There is no status. Open is completed_at IS NULL AND deleted_at IS NULL. Soft-deleted rows are filtered out of the query and never surface as a status.

There is no title. The only source is followup_sequence_steps.title, so a manually created task is always null here. The field stays in the shape so a caller does not have to branch, and writes reject it outright rather than accepting it and dropping it.

assignee_id is coalesce(assigned_to_id, user_id).

sequence is the only sanctioned view of the materialization internals. The raw sequence_id, enrollment_id, sequence_step_id, email_template_id, email_body and linkedin_text are all withheld.

FieldTypeNotes
object"task"
idstring
workspacestring
lead_idstring
reply_idstring
titlestring | null
notesstring | null
task_typestring | null
due_datestring | null
due_timestring | null
status"open" | "completed"
creator_idstring
assignee_idstring
completed_atstring | null
created_atstring | null
sequenceSerializedTaskSequence | null

meeting

host_id is assigned_ae_id, the round-robin-picked host whose calendar holds the event. booked_by_id is the operator who ran the booking form, which for a calendar booking is usually not the host. Keeping them apart matters for attribution: insights credit bookings to booked_by_id.

custom_fields keys are event_type_form_fields.field_key values.

Times can lag a host-side reschedule by up to 15 minutes, because the API read path deliberately skips syncUpcomingMeetingTimes: that helper makes a live Google or Microsoft call per row and writes corrected times back, which is a per-row cost on a per-request budget and burns the host's OAuth quota on a read.

meet_link and google_event_html_link are never returned in v1. Both are join capability URLs: anyone holding one can walk into the client's sales call. internal_notes is passed only on the single-object read and only for a key holding meetings:write; its schema comment is the most explicit internal-only marker in the whole database.

FieldTypeNotes
object"meeting"
idstring
workspacestring
lead_idstring | null
event_type_idstring | null
sourcestring
booked_atstring | null
booked_by_idstring | null
host_idstring | null
starts_atstring | null
ends_atstring | null
timezonestring | null
invitee{ name: string | null; email: string | null; phone: string | null }
emailstring | null
namestring | null
company_namestring | null
domainstring | null
guestsArray<{ name: string; email: string }>
custom_fieldsRecord<string, string | boolean>
utm_paramsRecord<string, string>
channelstring | null
confirmation_statusstring
location{ type: string | null; phone: string | null }
outcomestring | null
outcome_atstring | null
outcome_by_idstring | null
lockedboolean
created_atstring | null
internal_notes (optional)string | null

event_type

default_field_config is stored as {} when the built-in defaults apply, so this resolves it through resolveDefaultFieldConfig and always returns the concrete map. A caller never has to know the empty-object convention.

public_url is null when no public origin is configured, rather than a localhost link a caller would send to a prospect.

external_id is withheld: it is a provider-side event-type id that is only useful paired with the workspace's connector credential, so publishing it moves half of a credential pair outside the building. exclude_from_insights is an internal reporting flag. The whole of event_type_email_settings stays private: the subject and body templates are internal copy and host_notify_user_ids is internal routing.

FieldTypeNotes
object"event_type"
idstring
workspacestring
namestring
slugstring
descriptionstring | null
event_name_templatestring | null
duration_minutesnumber
buffer_before_minutesnumber
buffer_after_minutesnumber
scheduling_typestring
publicboolean
public_urlstring | null
public_booking_window_daysnumber
minimum_notice_minutesnumber
business_emails_onlyboolean
redirect_urlstring | null
workspace_wideboolean
activeboolean
requires_confirmationboolean
typestring | null
phonestring | null
descriptionstring | null
order_indexnumber
use_event_type_availabilityboolean
default_field_configDefaultFieldConfig
booking_providerstring
created_by_idstring | null
created_atstring | null
updated_atstring | null
hosts (optional)SerializedEventTypeHost[]
form_fields (optional)SerializedEventTypeFormField[]

event_type_host

member_id is the opaque form of event_type_hosts.user_id.

FieldTypeNotes
object"event_type_host"
idstring
event_type_idstring
member_idstring
weightnumber
prioritynumber
is_hostboolean
activeboolean

event_type_form_field

FieldTypeNotes
object"event_type_form_field"
idstring
event_type_idstring
field_keystring
labelstring
typestring
optionsstring[]
requiredboolean
order_indexnumber
activeboolean

slot

A start and an end, and nothing else, deliberately. No host identity on a slot, so a caller cannot enumerate who on the team is free when, which is private calendar information about a person rather than a fact about the workspace.

FieldTypeNotes
object"slot"
startstring | null
endstring | null

availability

reason carries a short cause when slots is empty and the cause is knowable (outside_booking_window, no_active_hosts, no_calendar_connected), so a caller can tell "nothing free" from "misconfigured".

Raw calendar busy blocks are never returned. They are the host's private calendar, and the free slots are the only part that is the caller's business.

FieldTypeNotes
object"availability"
event_type_idstring
duration_minutesnumber
timezonestring | null
window{ from: string | null; to: string | null }
slotsSerializedSlot[]
reasonstring | null

template

body is the RAW template, unresolved {{tokens}} and all. Never send it as an email: POST.../templates/{id}/render exists for exactly that, and it is the single most likely way an agent damages a client.

scope exists because of a real cross-tenant quirk. visibleWhere in the templates route returns every row with visibility = "global" from ANY workspace, with no client_id bound on that branch, so a list under one workspace legitimately contains rows owned by another. Rather than hide that, scope names it and a global template reports workspace: null, which keeps the nested-path invariant from being quietly broken.

visibility null is reported as workspace, matching how every read path in the app already treats it, so a caller never has to handle a third state that means the same as the second.

ai_variables exposes name and read_website only. instructions is withheld: it is free-text model instructions, which is internal copy and a prompt-injection surface if a caller can read and echo it. reply_templates.group is withheld as legacy bucketing superseded by folder_id.

FieldTypeNotes
object"template"
idstring
workspacestring | null
scope"workspace" | "global"
namestring
subjectstring | null
bodystring
is_htmlboolean
visibilitystring
folder_idstring | null
order_indexnumber
variablesstring[]
ai_variablesArray<{ name: string; read_website: boolean }>
created_by_idstring | null
created_atstring | null

template_folder

FieldTypeNotes
object"template_folder"
idstring
workspacestring
namestring
sort_ordernumber
template_countnumber
created_atstring | null

asset

url carries the link for loom, doc and link; body carries the snippet for text and is null otherwise.

client_assets.visibility has only TWO values, private and workspace. There is no global, unlike reply_templates.visibility and followup_sequences.visibility, which have three. An agent that learned visibility on templates and sends global here gets a 422, and GET /v1/capabilities calls the difference out.

Rows with visibility: "private" are filtered to the key's owner in the query, not here.

FieldTypeNotes
object"asset"
idstring
workspacestring
kindstring
titlestring
urlstring | null
bodystring | null
visibilitystring
order_indexnumber
created_by_idstring | null
created_atstring | null

sequence

Both forms of the sending window are returned. send_days is readable and send_days_mask is the value a write would have to send back, and these pages say the basis is UTC: a sequence set to weekdays fires on UTC weekdays, not the rep's.

followup_sequences.deleted_at is withheld and soft-deleted rows are filtered out of the query, so a caller never sees a tombstone.

FieldTypeNotes
object"sequence"
idstring
workspacestring
namestring
activeboolean
visibilitystring
send_daysstring[]
send_days_masknumber
created_by_idstring | null
created_atstring | null
steps (optional)SerializedSequenceStep[]

sequence_step

wait_for_previous is the column wait_for_prev, spelled out on the wire.

FieldTypeNotes
object"sequence_step"
idstring
order_indexnumber
typestring
titlestring | null
delay_valuenumber
delay_unitstring
wait_for_previousboolean
template_idstring | null
email_bodystring | null
linkedin_textstring | null

enrollment

followup_enrollments.client_id is notNull with NO foreign key, so the read path filters it and ALSO joins responses_index through response_id, which does carry a cascading foreign key. The raw column is withheld.

FieldTypeNotes
object"enrollment"
idstring
workspacestring
sequence_idstring | null
lead_idstring
reply_idstring
statusstring
next_step_indexnumber
enrolled_by_idstring | null
created_atstring | null

campaign_tag

These ids are the match key for assignment_rules.tag_ids and sdr_note_sets.tag_ids, both matched by Postgres array overlap.

FieldTypeNotes
object"campaign_tag"
idstring
workspacestring
namestring
campaign_countnumber

campaign

Proxied live from EmailBison with the per-workspace token from clients.eb_token. That token is stored in PLAIN TEXT, which is exactly why this is a hand-written five-field mapper and never a spread of an upstream response: no field EmailBison invents tomorrow can arrive on the wire by accident, and the token cannot ride along in a response, an error or a log.

The tags here are the inline { id, name } form, not full campaign_tag objects, so a campaign row does not carry a count nobody asked for.

FieldTypeNotes
object"campaign"
idstring
workspacestring
namestring
statusstring
tagsArray<{ id: string; name: string }>

rep_stat

RepStat.image is dropped: an avatar URL belongs on a member, and repeating it in an aggregate invites a caller to build a directory out of a report.

The booking-rate inputs are approximate and these pages say so. Interested is attributed through action_log and booked through scheduled_meetings.booked_by_id, and those two can disagree for the same rep.

FieldTypeNotes
member_idstring
namestring | null
meetings_bookednumber
showed_meetingsnumber
meetings_with_outcomenumber
show_ratenumber
interested_leadsnumber
booked_from_interestednumber
booking_ratenumber
first_response_avg_secnumber
first_response_countnumber
followup_avg_secnumber
followup_countnumber

speed_bucket

FieldTypeNotes
labelstring
nnumber
bookednumber
showednumber
booking_ratenumber
show_ratenumber

insights_summary

EVERY RATE IS A RATIO IN [0,1], never a percentage. A caller that multiplies by 100 twice is a documentation failure, and the way to avoid it is to never ship a percent.

meetingsTimeline and bookingTimeline are NOT included. Both are fixed "last 12 ISO weeks" series that ignore the requested window, so returning them next to a windowed funnel would put two different time ranges in one object. GET /insights/daily is the windowed series.

speed_to_lead is deploy-forward only: lead_response_times rows exist from the day the feature shipped, so a window reaching earlier under-counts.

generated_at is passed in as a JS Date. Never now() in SQL: every timestamp column here is timestamp without time zone read back as UTC, so a SQL now() writes local wall-clock and lands hours off.

FieldTypeNotes
object"insights_summary"
workspacestring
window{ from: string | null; to: string | null }
all_responsesnumber
positive_responsesnumber
booked_meetingsnumber
showed_meetingsnumber
total_meetings_bookednumber
meetings_with_outcomenumber
show_ratenumber
total_interested_leadsnumber
booked_interested_leadsnumber
booking_ratenumber
first_response_avg_secnumber
first_response_countnumber
followup_avg_secnumber
followup_countnumber
per_repSerializedRepStat[]
speed_bucketsSerializedSpeedBucket[]
generated_atstring | null

campaign_step

step_id is EbSequenceStepStat.sequence_step_id, a CADENCE step id, and it is the one raw upstream integer in the whole API. It is published because a caller comparing two runs needs to line the rows up, and it is not sensitive: it names a step inside a campaign the caller can already read. It is NOT durable, though. Removing a variant in EmailBison resets per-variant ids and breaks historical metrics, which is a documented production hazard.

FieldTypeNotes
object"campaign_step"
step_idnumber
labelstring
subjectstring
sentnumber
contactsnumber
repliesnumber
interestednumber

campaign_insight

The array is steps, not variants, on purpose. They come from EmailBison sequence steps, and the UI's "Variant A" labels are preserved, but calling the array variants would invite every consumer to read it as A/B test output. EmailBison stamps a reply with its campaign and not with the variant that produced the sent email, so there is no per-step booked or showed and none is invented.

There is no unique_opens. Every campaign ships open_tracking: false, so the number does not exist and a null field would only invite someone to chart it.

bounced here is a reporting number, not the deliverability gate the daily diagnosis runs on.

clientSlug and clientName from CampaignStat are dropped: the workspace is already in the request path, and repeating it per row is how a cross-client report gets built by accident.

FieldTypeNotes
object"campaign_insight"
campaign_idstring
campaign_namestring
statusstring
emails_sentnumber
contactsnumber
repliesnumber
interestednumber
bouncednumber
bookednumber
showednumber
tagsArray<{ id: string; name: string }>
stepsSerializedCampaignStep[]

daily_point

date is a UTC calendar day and the series is DENSE: a day with no activity comes back as zeros rather than being omitted, so a caller can chart it without gap-filling.

FieldTypeNotes
object"daily_point"
datestring
emails_sentnumber
responsesnumber
positive_responsesnumber
bookednumber

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