# Leads

The **lead inbox** holds new requests waiting for someone to reach out: website request forms, customer-portal requests and requests sent through the API.

When a lead comes in, TruTec runs the same intake as a web request form:

1. matches the person and company to records you already have, or creates them;
2. opens a **job** in the first open stage of your sales pipeline (and links the site from the address);
3. routes it to a teammate using your lead routing settings (round-robin, territory and service rules);
4. creates a "Call new lead" company follow-up due within your speed-to-lead window;
5. emails the assigned teammate (you can turn this off per request).

Nothing is sent to the lead.

In TruTec Heavy workspaces a **website request form** can instead add a basic lead to the Leads pipeline (Request forms → Routing → "Where website requests go"): no company, contact or job until someone moves it to sales. Leads sent through this API always get the full intake above.

A lead's `status` is `new`, `contacted`, `converted` ("Moving forward"), `dismissed` or `spam`. TruTec updates it on its own: a logged call, email or meeting with the person (or the "Call new lead" follow-up being completed) makes it **contacted**, and the job leaving its first stage makes it **Moving forward**.

!!! note
    Partner keys that act for several workspaces must also send `firm_id` (query string, or JSON body on writes).

## The lead object

| Field | Type | Description |
|---|---|---|
| `id` | integer | TruTec id. |
| `status`, `status_label` | string | See above. `converted` reads "Moving forward". |
| `name`, `email`, `phone`, `company`, `address` | string or null | What the person sent. |
| `services` | string or null | Requested services, as readable labels. |
| `source` | string or null | Form name, "Customer portal", or your `source` label. |
| `source_kind` | string | `form`, `portal`, `api` or `manual`. |
| `submitted_at` | string | When it came in (UTC). |
| `assigned_to_user_id`, `assigned_to` | integer, object | Who it went to (`{id, name}`). |
| `assignment_reason` | string or null | Why it went to them. |
| `first_contacted_at`, `first_contact_kind`, `first_contacted_by_user_id` | | When and how someone first reached out (`call`, `email`, `text`, `meeting`, `task`, `marked`). |
| `contact_id`, `client_account_id`, `job_id`, `location_id` | integer or null | The records the lead created or matched. |
| `follow_up_task_id` | integer or null | The "Call new lead" company follow-up (`GET /v1/account-tasks/{id}`). |
| `answers` | array | `[{label, value}]`, everything they told you. |
| `utm` | object | Campaign fields sent with the request. |
| `match_reasons` | array | Why an existing contact or company was matched. |
| `created_records` | object | Which records this lead created (`contact`, `account`, `job`, `location`). |
| `escalated_at` | string or null | When the owner was alerted that nobody had reached out. |
| `auto_spam` | boolean | The form flagged it as spam on its own. |
| `problem` | string or null | Set when the request was saved but its job could not be created. |
| `on_leads_pipeline` | boolean | TruTec Heavy: the lead waits on the Leads pipeline with no company or job yet (typed in with "Add lead", or a website request). Someone moves it to sales in TruTec, where the company is added; until then `convert` answers `409 no_job`. |
| `pipeline_stage_id` | integer or null | Its Leads pipeline stage. |
| `estimated_value`, `customer_type`, `notes`, `next_follow_up_at` | | What the team typed on a Leads pipeline lead. |
| `status_changed_at`, `created_at`, `updated_at` | string | UTC timestamps with `Z`. |

## Leads

### List leads

`GET /v1/leads`

**Permission:** Read jobs

Newest first. With `updated_after`, oldest change first.

| Name | Type | Required | Description |
|---|---|---|---|
| `status` | string | No | `all` (default), `open` (new + contacted), `new`, `contacted`, `converted`, `dismissed` or `spam`. |
| `assigned_to_user_id` | integer | No | Only this teammate's leads. |
| `source_kind` | string | No | `form`, `portal`, `api` or `manual`. |
| `q` | string | No | Name, email, phone, company or address contains. |
| `updated_after` | string | No | ISO 8601. |
| `limit`, `offset` | integer | No | Paging (default 50, max 200). |

```bash
curl "https://trutec.ai/api/v1/leads?status=open" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "data": [
    {
      "id": 902,
      "status": "new",
      "status_label": "New",
      "name": "Jordan Ellis",
      "display_name": "Jordan Ellis",
      "email": "jordan@lakeside.example",
      "phone": "(512) 555-0199",
      "company": "Lakeside Commons",
      "address": "400 Lake Rd, Austin, TX 78701",
      "services": "Sealcoating",
      "source": "API",
      "source_kind": "api",
      "submitted_at": "2026-10-12T14:00:00Z",
      "assigned_to_user_id": 118,
      "assigned_to": {"id": 118, "name": "Dana Whitfield"},
      "assignment_reason": "Round-robin",
      "first_contacted_at": null,
      "first_contact_kind": null,
      "first_contacted_by_user_id": null,
      "contact_id": 20500,
      "client_account_id": 4901,
      "job_id": 41901,
      "location_id": 9411,
      "follow_up_task_id": 3310,
      "answers": [{"label": "Name", "value": "Jordan Ellis"}, {"label": "Services", "value": "Sealcoating"}],
      "utm": {},
      "match_reasons": [],
      "created_records": {"contact": true, "account": true, "job": true},
      "escalated_at": null,
      "auto_spam": false,
      "problem": null,
      "status_changed_at": null,
      "created_at": "2026-10-12T14:00:00Z",
      "updated_at": "2026-10-12T14:00:00Z"
    }
  ],
  "pagination": {"total": 1, "limit": 50, "offset": 0}
}
```

### Get a lead

`GET /v1/leads/{id}`

**Permission:** Read jobs

Returns `{"data": {lead}}`. **Errors:** `404` `Lead not found`.

### Create a lead

`POST /v1/leads`

**Permission:** Write jobs

Use this for requests from your own website or another system. If you are syncing jobs you already have, use `POST /v1/jobs` instead (see [Jobs](jobs.md)); those are not new leads.

| Name | Type | Required | Description |
|---|---|---|---|
| `name` | string | Yes | Who is asking. |
| `email` | string | One of | At least an email or a phone. |
| `phone` | string | One of | 10 to 15 digits with area code. |
| `company` | string | No | Company or property name. |
| `address` | string | No | Property address. |
| `message` | string | No | What they need. Up to 3,000 characters. |
| `services` | array of strings | No | Job-type values from `GET /v1/job-types`, e.g. `["sealcoating", "striping"]`. |
| `timing` | string | No | `asap`, `30_days`, `this_season`, `budgeting` or `unsure`. |
| `property_type` | string | No | One of the workspace's property types. |
| `source` | string | No | Label shown in the inbox, e.g. "Spring mailer". Default `API`. |
| `utm` | object | No | Campaign fields such as `utm_source`, `utm_campaign`, `gclid`. |
| `notify` | boolean | No | Email the assigned teammate the new-lead alert. Default `true`. |

```bash
curl -X POST "https://trutec.ai/api/v1/leads" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Jordan Ellis",
    "email": "jordan@lakeside.example",
    "phone": "(512) 555-0199",
    "company": "Lakeside Commons",
    "address": "400 Lake Rd, Austin, TX 78701",
    "services": ["sealcoating"],
    "message": "Lot needs a reseal before spring"
  }'
```

Returns `201` with `{"data": {lead}}`. If something fails part-way, the lead is still saved with its answers and `problem` is set, so nobody loses a request.

**Errors:** `400` `name is required`, `Send an email or a phone number so someone can reach them.`, `Unknown services: ...`, a bad `timing` or `property_type`, or unknown fields.

### Mark a lead contacted

`POST /v1/leads/{id}/contacted`

**Permission:** Write jobs

Someone reached out. A `call`, `email` or `meeting` is logged on the job and the company feed (with `note`); a `text` is noted on the person. The "Call new lead" follow-up closes.

| Name | Type | Required | Description |
|---|---|---|---|
| `how` | string | No | `call`, `email`, `text`, `meeting` or `other` (default). |
| `note` | string | No | What happened. |

Returns `{"data": {lead}}`. **Errors:** `409` `Restore this lead first.` for a dismissed or spam lead.

### Assign a lead

`POST /v1/leads/{id}/assign`

**Permission:** Write jobs

Hands the lead, its job and its open follow-up to `user_id` (an office teammate). A new lead that hasn't been contacted yet emails them the new-lead alert. Returns `{"data": {lead}}`.

**Errors:** `400` `user_id is required`; `404` when the user is not an office teammate in the workspace.

### Move a lead forward

`POST /v1/leads/{id}/convert`

**Permission:** Write jobs

Every lead already has a job. This moves that job to a later stage, with the same rules as `POST /v1/jobs/{id}/move` (see [Jobs](jobs.md)), and the inbox then shows the lead as "Moving forward".

| Name | Type | Required | Description |
|---|---|---|---|
| `status` | string | No | An open or won stage key from `GET /v1/job-stages`. Default: the next open stage after the one the lead came in at. |
| `confirm_schedule_completion` | boolean | No | Needed when the move would complete open work orders. |

```bash
curl -X POST "https://trutec.ai/api/v1/leads/902/convert" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"status": "bid_sent"}'
```

Returns `{"data": {lead}, "job": {job}}`.

**Errors:** `400` for a lost or excluded stage (close a lead with `/dismiss`, or move its job with `/v1/jobs/{id}/move`), or when there is no later open stage; `409` `lead_closed`, `no_job` or `confirmation_required`; `403` `status_locked`.

### Dismiss a lead

`POST /v1/leads/{id}/dismiss`

**Permission:** Write jobs

Takes the lead out of the inbox. Send `{"spam": true}` to mark it spam instead: the contact and company the lead created are archived too, when nothing else uses them. Its follow-up closes. Returns `{"data": {lead}, "message": "..."}`.

### Restore a lead

`POST /v1/leads/{id}/restore`

**Permission:** Write jobs

Back into the inbox, as `new` or (if someone already reached out) `contacted`; anything archived by marking it spam comes back. A submission the form flagged as spam on its own has no job yet: it runs through the full intake now and returns as a new lead, with `replaced_lead_id` naming the old one.

### Edit or delete a lead

A lead is the request as it came in, so it can't be edited or deleted, in the app or through the API. Change what it created instead: the person (`PATCH /v1/contacts/{id}`), the company (`PATCH /v1/client-accounts/{id}`) or the job (`PATCH /v1/jobs/{id}`), using the `contact_id`, `client_account_id` and `job_id` on the lead. To take a lead out of the inbox, [dismiss it](#dismiss-a-lead) (or mark it spam).
