# Object schemas

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

Source: https://docs.leadarc.io/objects.html

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

[`workspace`](#workspace) · [`member`](#member) · [`lead`](#lead) · [`reply`](#reply) · [`attachment`](#attachment) · [`message`](#message) · [`note`](#note) · [`task_sequence`](#task-sequence) · [`task`](#task) · [`meeting`](#meeting) · [`event_type`](#event-type) · [`event_type_host`](#event-type-host) · [`event_type_form_field`](#event-type-form-field) · [`slot`](#slot) · [`availability`](#availability) · [`template`](#template) · [`template_folder`](#template-folder) · [`asset`](#asset) · [`sequence`](#sequence) · [`sequence_step`](#sequence-step) · [`enrollment`](#enrollment) · [`campaign_tag`](#campaign-tag) · [`campaign`](#campaign) · [`rep_stat`](#rep-stat) · [`speed_bucket`](#speed-bucket) · [`insights_summary`](#insights-summary) · [`campaign_step`](#campaign-step) · [`campaign_insight`](#campaign-insight) · [`daily_point`](#daily-point)

## Reading these tables

- Every object has an `object` field naming its type, so a mixed list is still parseable.
- `string | null` means the field is always there and can be null. *Optional* means the key can be missing, which is how expansions work.
- Ids are opaque. The prefix gives the kind: `lead_`, `rep_`, `msg_`, `tsk_`, `mtg_`, `note_`, `tpl_`, `evt_`, `seq_`, `ast_`, `cmp_`, `usr_`. A workspace id is its slug.
- Timestamps are ISO 8601 UTC. Days are `YYYY-MM-DD`.

## 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.

| Class | Meaning |
|---|---|
| `stable` | A real column. The same for every caller, forever. |
| `derived` | Computed from stable columns. The same for every caller. |
| `key-scoped` | Depends on which key asked, because it depends on the key's owner. |
| `best-effort` | A heuristic or a provider-dependent value with documented failure modes. |
| `upstream` | Proxied 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.

| Table | Withheld columns |
| --- | --- |
| `clients` | `id` `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` |
| `users` | `password_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_index` | `notified_at` `last_read_at` `disqualified_by` `booked_by` |
| `lead_notes` | `client_id` `response_id` |
| `tasks` | `deleted_at` `email_body` `linkedin_text` `email_template_id` `enrollment_id` `sequence_step_id` `company_id` `opportunity_id` |
| `scheduled_meetings` | `meet_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_types` | `external_id` `exclude_from_insights` `created_by_user_id` |
| `event_type_email_settings` | `*` |
| `reply_templates` | `ai_vars.instructions` `group` |
| `followup_sequences` | `deleted_at` |
| `followup_enrollments` | `client_id` |
| `thread_cache` | `payload` `updated_at` |
| `reply_attachments` | `download_url` `uuid` |
| `api_keys` | `secret_hash` `key_id` `created_by_id` `revoked_by_id` `last_used_ip_prefix` `request_count` |
| `enrichment_events` | `phone` |

## 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=`.

| Field | Type | Notes |
| --- | --- | --- |
| `object` | `"workspace"` |  |
| `id` | `string` |  |
| `name` | `string` |  |
| `domain` | `string \| null` |  |
| `logo_url` | `string \| null` |  |
| `active` | `boolean` |  |
| `features` | `Record<string, boolean>` |  |
| `granted_scopes` | `string[]` |  |
| `synced_at` | `string \| 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.

| Field | Type | Notes |
| --- | --- | --- |
| `object` | `"member"` |  |
| `id` | `string` |  |
| `name` | `string \| null` |  |
| `email` | `string` |  |
| `avatar_url` | `string \| null` |  |
| `role` | `"admin" \| "user"` |  |
| `timezone` | `string \| null` |  |
| `active` | `boolean` |  |
| `assignable` | `boolean` |  |
| `created_at` | `string \| 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.

| Field | Type | Notes |
| --- | --- | --- |
| `object` | `"lead"` |  |
| `id` | `string` |  |
| `workspace` | `string` |  |
| `email` | `string` |  |
| `first_name` | `string \| null` |  |
| `last_name` | `string \| null` |  |
| `company_name` | `string \| null` |  |
| `title` | `string \| null` |  |
| `status` | `LeadStatus` |  |
| `classification` | `string \| null` |  |
| `state` | `LeadState` |  |
| `subject` | `string \| null` |  |
| `body_preview` | `string \| null` |  |
| `latest_reply_id` | `string` |  |
| `last_message_at` | `string \| null` |  |
| `last_message_direction` | `string` |  |
| `last_action_at` | `string \| null` |  |
| `booked_at` | `string \| null` |  |
| `disqualified_at` | `string \| null` |  |
| `meeting_outcome` | `string \| null` |  |
| `meeting_outcome_at` | `string \| null` |  |
| `responded` | `boolean` |  |
| `unread` | `boolean` |  |
| `campaign_id` | `string \| null` |  |
| `campaign_name` | `string \| null` |  |
| `assignee_ids` | `string[]` |  |
| `has_open_task` | `boolean` |  |
| `next_due_at` | `string \| null` |  |
| `indexed_at` | `string \| 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.

| Field | Type | Notes |
| --- | --- | --- |
| `object` | `"reply"` |  |
| `id` | `string` |  |
| `lead_id` | `string` |  |
| `workspace` | `string` |  |
| `email` | `string` |  |
| `subject` | `string \| null` |  |
| `body_preview` | `string \| null` |  |
| `status` | `string \| null` |  |
| `classification` | `string \| null` |  |
| `direction` | `string` |  |
| `received_at` | `string \| null` |  |
| `campaign_id` | `string \| null` |  |
| `campaign_name` | `string \| 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.

| Field | Type | Notes |
| --- | --- | --- |
| `object` | `"attachment"` |  |
| `id` | `string` |  |
| `filename` | `string` |  |

### `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.

| Field | Type | Notes |
| --- | --- | --- |
| `object` | `"message"` |  |
| `id` | `string` |  |
| `lead_id` | `string` |  |
| `kind` | `"reply" \| "campaign_send"` |  |
| `direction` | `"inbound" \| "outbound"` |  |
| `reply_id` | `string \| null` |  |
| `subject` | `string \| null` |  |
| `from_name` | `string \| null` |  |
| `from_email` | `string \| null` |  |
| `to_email` | `string \| null` |  |
| `body_html` | `string \| null` |  |
| `body_text` | `string \| null` |  |
| `sent_at` | `string \| null` |  |
| `attachments` | `SerializedAttachment[]` |  |

### `note`

| Field | Type | Notes |
| --- | --- | --- |
| `object` | `"note"` |  |
| `id` | `string` |  |
| `workspace` | `string` |  |
| `lead_id` | `string` |  |
| `reply_id` | `string \| null` |  |
| `body` | `string \| null` |  |
| `author_id` | `string \| null` |  |
| `created_at` | `string \| null` |  |
| `updated_at` | `string \| null` |  |

### `task_sequence`

| Field | Type | Notes |
| --- | --- | --- |
| `id` | `string` |  |
| `name` | `string \| null` |  |
| `step_index` | `number \| 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.

| Field | Type | Notes |
| --- | --- | --- |
| `object` | `"task"` |  |
| `id` | `string` |  |
| `workspace` | `string` |  |
| `lead_id` | `string` |  |
| `reply_id` | `string` |  |
| `title` | `string \| null` |  |
| `notes` | `string \| null` |  |
| `task_type` | `string \| null` |  |
| `due_date` | `string \| null` |  |
| `due_time` | `string \| null` |  |
| `status` | `"open" \| "completed"` |  |
| `creator_id` | `string` |  |
| `assignee_id` | `string` |  |
| `completed_at` | `string \| null` |  |
| `created_at` | `string \| null` |  |
| `sequence` | `SerializedTaskSequence \| 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`](scopes.html#all); its schema comment is the most explicit internal-only marker in the whole database.

| Field | Type | Notes |
| --- | --- | --- |
| `object` | `"meeting"` |  |
| `id` | `string` |  |
| `workspace` | `string` |  |
| `lead_id` | `string \| null` |  |
| `event_type_id` | `string \| null` |  |
| `source` | `string` |  |
| `booked_at` | `string \| null` |  |
| `booked_by_id` | `string \| null` |  |
| `host_id` | `string \| null` |  |
| `starts_at` | `string \| null` |  |
| `ends_at` | `string \| null` |  |
| `timezone` | `string \| null` |  |
| `invitee` | `{ name: string \| null; email: string \| null; phone: string \| null }` |  |
| `email` | `string \| null` |  |
| `name` | `string \| null` |  |
| `company_name` | `string \| null` |  |
| `domain` | `string \| null` |  |
| `guests` | `Array<{ name: string; email: string }>` |  |
| `custom_fields` | `Record<string, string \| boolean>` |  |
| `utm_params` | `Record<string, string>` |  |
| `channel` | `string \| null` |  |
| `confirmation_status` | `string` |  |
| `location` | `{ type: string \| null; phone: string \| null }` |  |
| `outcome` | `string \| null` |  |
| `outcome_at` | `string \| null` |  |
| `outcome_by_id` | `string \| null` |  |
| `locked` | `boolean` |  |
| `created_at` | `string \| 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.

| Field | Type | Notes |
| --- | --- | --- |
| `object` | `"event_type"` |  |
| `id` | `string` |  |
| `workspace` | `string` |  |
| `name` | `string` |  |
| `slug` | `string` |  |
| `description` | `string \| null` |  |
| `event_name_template` | `string \| null` |  |
| `duration_minutes` | `number` |  |
| `buffer_before_minutes` | `number` |  |
| `buffer_after_minutes` | `number` |  |
| `scheduling_type` | `string` |  |
| `public` | `boolean` |  |
| `public_url` | `string \| null` |  |
| `public_booking_window_days` | `number` |  |
| `minimum_notice_minutes` | `number` |  |
| `business_emails_only` | `boolean` |  |
| `redirect_url` | `string \| null` |  |
| `workspace_wide` | `boolean` |  |
| `active` | `boolean` |  |
| `requires_confirmation` | `boolean` |  |
| `type` | `string \| null` |  |
| `phone` | `string \| null` |  |
| `description` | `string \| null` |  |
| `order_index` | `number` |  |
| `use_event_type_availability` | `boolean` |  |
| `default_field_config` | `DefaultFieldConfig` |  |
| `booking_provider` | `string` |  |
| `created_by_id` | `string \| null` |  |
| `created_at` | `string \| null` |  |
| `updated_at` | `string \| null` |  |
| `hosts` *(optional)* | `SerializedEventTypeHost[]` |  |
| `form_fields` *(optional)* | `SerializedEventTypeFormField[]` |  |

### `event_type_host`

`member_id` is the opaque form of `event_type_hosts.user_id`.

| Field | Type | Notes |
| --- | --- | --- |
| `object` | `"event_type_host"` |  |
| `id` | `string` |  |
| `event_type_id` | `string` |  |
| `member_id` | `string` |  |
| `weight` | `number` |  |
| `priority` | `number` |  |
| `is_host` | `boolean` |  |
| `active` | `boolean` |  |

### `event_type_form_field`

| Field | Type | Notes |
| --- | --- | --- |
| `object` | `"event_type_form_field"` |  |
| `id` | `string` |  |
| `event_type_id` | `string` |  |
| `field_key` | `string` |  |
| `label` | `string` |  |
| `type` | `string` |  |
| `options` | `string[]` |  |
| `required` | `boolean` |  |
| `order_index` | `number` |  |
| `active` | `boolean` |  |

### `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.

| Field | Type | Notes |
| --- | --- | --- |
| `object` | `"slot"` |  |
| `start` | `string \| null` |  |
| `end` | `string \| 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.

| Field | Type | Notes |
| --- | --- | --- |
| `object` | `"availability"` |  |
| `event_type_id` | `string` |  |
| `duration_minutes` | `number` |  |
| `timezone` | `string \| null` |  |
| `window` | `{ from: string \| null; to: string \| null }` |  |
| `slots` | `SerializedSlot[]` |  |
| `reason` | `string \| 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`.

| Field | Type | Notes |
| --- | --- | --- |
| `object` | `"template"` |  |
| `id` | `string` |  |
| `workspace` | `string \| null` |  |
| `scope` | `"workspace" \| "global"` |  |
| `name` | `string` |  |
| `subject` | `string \| null` |  |
| `body` | `string` |  |
| `is_html` | `boolean` |  |
| `visibility` | `string` |  |
| `folder_id` | `string \| null` |  |
| `order_index` | `number` |  |
| `variables` | `string[]` |  |
| `ai_variables` | `Array<{ name: string; read_website: boolean }>` |  |
| `created_by_id` | `string \| null` |  |
| `created_at` | `string \| null` |  |

### `template_folder`

| Field | Type | Notes |
| --- | --- | --- |
| `object` | `"template_folder"` |  |
| `id` | `string` |  |
| `workspace` | `string` |  |
| `name` | `string` |  |
| `sort_order` | `number` |  |
| `template_count` | `number` |  |
| `created_at` | `string \| 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.

| Field | Type | Notes |
| --- | --- | --- |
| `object` | `"asset"` |  |
| `id` | `string` |  |
| `workspace` | `string` |  |
| `kind` | `string` |  |
| `title` | `string` |  |
| `url` | `string \| null` |  |
| `body` | `string \| null` |  |
| `visibility` | `string` |  |
| `order_index` | `number` |  |
| `created_by_id` | `string \| null` |  |
| `created_at` | `string \| 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.

| Field | Type | Notes |
| --- | --- | --- |
| `object` | `"sequence"` |  |
| `id` | `string` |  |
| `workspace` | `string` |  |
| `name` | `string` |  |
| `active` | `boolean` |  |
| `visibility` | `string` |  |
| `send_days` | `string[]` |  |
| `send_days_mask` | `number` |  |
| `created_by_id` | `string \| null` |  |
| `created_at` | `string \| null` |  |
| `steps` *(optional)* | `SerializedSequenceStep[]` |  |

### `sequence_step`

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

| Field | Type | Notes |
| --- | --- | --- |
| `object` | `"sequence_step"` |  |
| `id` | `string` |  |
| `order_index` | `number` |  |
| `type` | `string` |  |
| `title` | `string \| null` |  |
| `delay_value` | `number` |  |
| `delay_unit` | `string` |  |
| `wait_for_previous` | `boolean` |  |
| `template_id` | `string \| null` |  |
| `email_body` | `string \| null` |  |
| `linkedin_text` | `string \| 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.

| Field | Type | Notes |
| --- | --- | --- |
| `object` | `"enrollment"` |  |
| `id` | `string` |  |
| `workspace` | `string` |  |
| `sequence_id` | `string \| null` |  |
| `lead_id` | `string` |  |
| `reply_id` | `string` |  |
| `status` | `string` |  |
| `next_step_index` | `number` |  |
| `enrolled_by_id` | `string \| null` |  |
| `created_at` | `string \| 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.

| Field | Type | Notes |
| --- | --- | --- |
| `object` | `"campaign_tag"` |  |
| `id` | `string` |  |
| `workspace` | `string` |  |
| `name` | `string` |  |
| `campaign_count` | `number` |  |

### `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.

| Field | Type | Notes |
| --- | --- | --- |
| `object` | `"campaign"` |  |
| `id` | `string` |  |
| `workspace` | `string` |  |
| `name` | `string` |  |
| `status` | `string` |  |
| `tags` | `Array<{ 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.

| Field | Type | Notes |
| --- | --- | --- |
| `member_id` | `string` |  |
| `name` | `string \| null` |  |
| `meetings_booked` | `number` |  |
| `showed_meetings` | `number` |  |
| `meetings_with_outcome` | `number` |  |
| `show_rate` | `number` |  |
| `interested_leads` | `number` |  |
| `booked_from_interested` | `number` |  |
| `booking_rate` | `number` |  |
| `first_response_avg_sec` | `number` |  |
| `first_response_count` | `number` |  |
| `followup_avg_sec` | `number` |  |
| `followup_count` | `number` |  |

### `speed_bucket`

| Field | Type | Notes |
| --- | --- | --- |
| `label` | `string` |  |
| `n` | `number` |  |
| `booked` | `number` |  |
| `showed` | `number` |  |
| `booking_rate` | `number` |  |
| `show_rate` | `number` |  |

### `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.

| Field | Type | Notes |
| --- | --- | --- |
| `object` | `"insights_summary"` |  |
| `workspace` | `string` |  |
| `window` | `{ from: string \| null; to: string \| null }` |  |
| `all_responses` | `number` |  |
| `positive_responses` | `number` |  |
| `booked_meetings` | `number` |  |
| `showed_meetings` | `number` |  |
| `total_meetings_booked` | `number` |  |
| `meetings_with_outcome` | `number` |  |
| `show_rate` | `number` |  |
| `total_interested_leads` | `number` |  |
| `booked_interested_leads` | `number` |  |
| `booking_rate` | `number` |  |
| `first_response_avg_sec` | `number` |  |
| `first_response_count` | `number` |  |
| `followup_avg_sec` | `number` |  |
| `followup_count` | `number` |  |
| `per_rep` | `SerializedRepStat[]` |  |
| `speed_buckets` | `SerializedSpeedBucket[]` |  |
| `generated_at` | `string \| 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.

| Field | Type | Notes |
| --- | --- | --- |
| `object` | `"campaign_step"` |  |
| `step_id` | `number` |  |
| `label` | `string` |  |
| `subject` | `string` |  |
| `sent` | `number` |  |
| `contacts` | `number` |  |
| `replies` | `number` |  |
| `interested` | `number` |  |

### `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.

| Field | Type | Notes |
| --- | --- | --- |
| `object` | `"campaign_insight"` |  |
| `campaign_id` | `string` |  |
| `campaign_name` | `string` |  |
| `status` | `string` |  |
| `emails_sent` | `number` |  |
| `contacts` | `number` |  |
| `replies` | `number` |  |
| `interested` | `number` |  |
| `bounced` | `number` |  |
| `booked` | `number` |  |
| `showed` | `number` |  |
| `tags` | `Array<{ id: string; name: string }>` |  |
| `steps` | `SerializedCampaignStep[]` |  |

### `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.

| Field | Type | Notes |
| --- | --- | --- |
| `object` | `"daily_point"` |  |
| `date` | `string` |  |
| `emails_sent` | `number` |  |
| `responses` | `number` |  |
| `positive_responses` | `number` |  |
| `booked` | `number` |  |

---

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