Leads
Send new requests into the lead inbox from your website or other systems, and read, update, assign, mark contacted, convert or dismiss them.
View as MarkdownThe 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:
- matches the person and company to records you already have, or creates them;
- opens a job in the first open stage of your sales pipeline (and links the site from the address);
- routes it to a teammate using your lead routing settings (round-robin, territory and service rules);
- creates a "Call new lead" company follow-up due within your speed-to-lead window;
- 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 withPATCH /v1/leads/{id}. - 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 (
statusconverted,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 withcreated: false, never a second job or HubSpot deal. See Convert a lead.
Every change fires lead events (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). |
curl "https://trutec.ai/api/v1/leads?status=open" \
-H "X-API-Key: sk_your_api_key_here"
{
"data": [
{
"id": 902,
"status": "new",
"status_label": "New",
"name": "Jordan Ellis",
"display_name": "Jordan Ellis",
"email": "[email protected]",
"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); 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). |
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": "[email protected]",
"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.
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. |
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. |
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. |
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), 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. |
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 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 (or mark it spam).