# 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, unless the workspace uses the lead pipeline below.

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

### The lead pipeline (leads stay in the inbox until Convert)

A workspace can turn on **Leads stay in the Lead inbox until you Convert them** (Request forms → Lead routing → Lead pipeline; owners and admins). The response's `lead_pipeline` field says whether it is on. With it on:

- A new lead, from a form, this API or "+ New lead" in the app, **gets no job, site or new company**. It is matched to a contact and company you already have when one fits (`contact_id`, `client_account_id`), routed and alerted as usual.
- Stages: `new` → `contacted` → `engaged`. Exits: `nurture` (needs a date; the lead shows under New again when the date comes), `unqualified` (needs a reason), `do_not_contact` (also flags the linked contact). Change them with [`PATCH /v1/leads/{id}`](#update-a-lead).
- A **qualification checklist** (`qualification`): work type, site address and timing, plus any job fields the workspace requires on new jobs (`required_job_fields`).
- **Convert** is the only way to **Qualified** (`status` `converted`, `status_label` "Qualified"). One call closes the lead and creates the job in the first deal stage, with its site, company and contact. It is retry-safe: converting a lead that already has its job returns that job with `created: false`, never a second job or HubSpot deal. See [Convert a lead](#convert-a-lead).

Every change fires [lead events](webhooks-and-events.md) (`lead_created`, `lead_updated`, `lead_status_changed`, `lead_converted`), whoever made it: the app, this API or an automation.

!!! 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. |
| `external_ref` | string or null | Your id for the lead (e.g. the HubSpot lead id). Unique per workspace. With Settings → HubSpot → "Sync leads with HubSpot" on, TruTec's own sync uses `hubspot:lead:<id>` (or `hubspot:contact:<id>` where the HubSpot account has no Leads object); a lead you post with your own `external_ref` never gets a second HubSpot Lead. |
| `status`, `status_label` | string | See above. `converted` reads "Moving forward", or "Qualified" with the lead pipeline on. |
| `stage` | object | `{status, label, is_open, is_qualified, nurture_due}`. |
| `lead_pipeline` | boolean | The workspace keeps leads in the inbox until Convert. |
| `qualification` | object | `{work_types, work_type_labels, site_address, timing, timing_label, timing_date, job_fields, required_job_fields, complete, missing}`. Until someone fills it in, it shows what the request said. `required_job_fields` lists `{key, label, type, options, required}` the workspace's New Job form insists on; answer them in `job_fields`. |
| `unqualified_reason`, `unqualified_note` | string or null | Set while the lead is `unqualified`. |
| `nurture_until` | string or null | When a `nurture` lead comes back (UTC). |
| `job_number`, `job_external_ref`, `converted_at` | string or null | The job Convert made: its number (e.g. `J-1204`), your id for it, and when. |
| `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. |
| `last_touch_at` | string or null | The last call, email, text or meeting logged on the 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, engaged, nurture), `closed` (everything else), or one status: `new`, `contacted`, `engaged`, `nurture`, `converted`, `unqualified`, `do_not_contact`, `dismissed`, `spam`. |
| `external_ref` | string | No | The lead carrying your id. |
| `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`. |
| `external_ref` | string | No | Your id for the lead, up to 120 characters. Makes the call an upsert: a lead that already carries it is updated with the fields you send (`200`, `created: false`; `name` is then optional); otherwise the lead is created (`201`, `created: 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}, "created": true}`. If something fails part-way, the lead is still saved with its answers and `problem` is set, so nobody loses a request. With the lead pipeline on, no job is made until [Convert](#convert-a-lead).

### Update a lead

`PATCH /v1/leads/{id}`

**Permission:** Write jobs

Send only what changes. Everything is checked first; when anything is refused, nothing changes.

| Name | Type | Required | Description |
|---|---|---|---|
| `status` | string | No | `new`, `contacted`, `engaged`, `nurture`, `unqualified`, `do_not_contact`, `dismissed` or `spam`. `converted` (Qualified) only comes from [Convert](#convert-a-lead). |
| `nurture_until` | string | With `nurture` | When to check back: `YYYY-MM-DD` (8 am workspace time) or ISO 8601. |
| `reason` | string | With `unqualified` | `not_our_work`, `outside_area`, `too_small`, `price_shopper`, `duplicate` or `other` (or its label, e.g. "Too small"). |
| `note` | string | No | A note with the stage change. |
| `assigned_to_user_id` | integer | No | Hand it to an office teammate (they get the new-lead email while it is new). |
| `assigned_to_email` | string | No | The same, by the teammate's login email. |
| `work_types` | array of strings | No | Checklist: job-type values from `GET /v1/job-types`. |
| `site_address` | string | No | Checklist: the site. |
| `timing` | string | No | Checklist: `asap`, `within_2_weeks`, `within_30_days`, `this_season` or `before_date`. |
| `timing_date` | string | With `before_date` | `YYYY-MM-DD`. |
| `job_fields` | object | No | Answers to `qualification.required_job_fields`, by `key`. |
| `notes` | string | No | Notes on the lead. |
| `follow_up_at` | string | No | Next follow-up, ISO 8601 or `YYYY-MM-DD`. |
| `external_ref` | string or null | No | Your id for the lead. |
| `name`, `email`, `phone`, `company`, `address`, `source`, `utm` | | No | The person's details, as on create. |

```bash
curl -X PATCH "https://trutec.ai/api/v1/leads/902" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"status": "nurture", "nurture_until": "2026-11-15", "note": "Budget opens in spring"}'
```

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

**Errors:** `400` bad values or unknown fields; `404` the lead or teammate isn't in the workspace; `409` `use_convert`, `lead_converted` (a Qualified lead has its job) or `external_ref_taken`; `422` `date_required` (nurture) or `reason_required` (unqualified).

**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. With the lead pipeline on, the touch is logged on the lead itself (its activity) and a `new` lead becomes `contacted`.

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

### Convert a lead

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

**Permission:** Write jobs

**With the lead pipeline on**, this is the one-step Convert, the same as the button in the inbox. In one transaction the lead closes as Qualified and the job is created in the first deal stage of the sales pipeline: the company (their company name, else the person's name) and contact are found or created, the site comes from the address, the job types from `work_types`, the salesperson is the lead's assignee, and the source, first touch and campaign are copied onto the job. Then the usual new-job steps run: job number, HubSpot deal (when your HubSpot connection creates deals) and webhooks.

The lead's checklist must be complete. Send any missing answers in the same call:

| Name | Type | Required | Description |
|---|---|---|---|
| `work_types` | array of strings | If not on the lead | Job-type values. |
| `site_address` | string | If not on the lead | The site. |
| `timing` | string | If not on the lead | `asap`, `within_2_weeks`, `within_30_days`, `this_season` or `before_date`. |
| `timing_date` | string | With `before_date` | `YYYY-MM-DD`. |
| `job_fields` | object | If the workspace requires them | Answers to `qualification.required_job_fields`, by `key`. |
| `job_external_ref` | string | No | Your id for the new job (e.g. the HubSpot deal id), saved as the job's `external_ref`. |

```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 '{"work_types": ["sealcoating"], "site_address": "400 Lake Rd, Austin, TX 78701", "timing": "within_30_days"}'
```

Returns `201` with `{"data": {lead}, "job": {job}, "created": true, "job_id": 41901, "job_number": "J-1204", "lead_external_ref": "...", "job_external_ref": "..."}`. **Retry-safe:** calling it again for a lead that already has its job returns that job with `200` and `created: false`. Nothing is made twice.

**Errors:** `409` `lead_closed` (restore it first), `job_external_ref_taken` or `jobs_from_workman`; `422` `qualification_incomplete` with `missing` (the checklist items still empty); `400` bad values or unknown fields.

**With the lead pipeline off**, 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. This also reopens `unqualified` and `do_not_contact` leads (the contact's do-not-contact flag set by the lead is cleared). 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.

### Delete a lead

A lead is the request as it came in, so it can't be deleted, in the app or through the API. [Update](#update-a-lead) its stage, assignee, checklist or details, and change what it created through its own endpoints: 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).
