Customers & sites
Create, find, update and archive the customer companies and job sites in a workspace, keyed by your own ids, and read their people, jobs and re-bids.
View as MarkdownCustomers are the companies you do work for. Sites are the physical properties where the work happens. A job points at one customer and one site.
In the API, customers are called client accounts (/v1/client-accounts) and sites are called locations (/v1/locations). The names differ from the app, but the records are the same.
How it fits together#
- A customer has a name, billing details and an optional parent (one level of divisions under a parent company).
- A site has an address, a name and an optional customer.
- A job links to both through
client_account_idandlocation_id. See Jobs.
TruTec also creates these records on its own. Creating a job with a company name links it to the customer with that name, or creates one. Creating a job with an address links it to the site at that address, or creates one. If you seed customers and sites first, new jobs link to your records instead of making new ones.
Every customer and site can carry an external_ref: your system's id for it. It is unique per workspace. Use it to look records up and to make your writes idempotent.
Note
Partner keys that act on behalf of several workspaces must also name the target workspace with firm_id (query string, or JSON body on writes). Workspace keys never need it.
Response format#
List endpoints return a page of records plus pagination info:
{
"data": [ ... ],
"pagination": { "total": 212, "limit": 50, "offset": 0 }
}
Page with limit and offset. Single-record writes return {"data": {...}}. Errors return JSON with an error string, for example {"error": "name is required"}.
Timestamps on these records are UTC in ISO 8601 without a timezone suffix, for example 2026-10-02T09:41:55.330019.
The customer object#
| Field | Type | Description |
|---|---|---|
id |
integer | TruTec id. Never changes. |
name |
string | Company name. Unique per workspace. |
display_name |
string | Name with surrounding whitespace removed. |
external_ref |
string or null | Your id for this customer. Unique per workspace. HubSpot-linked customers use hubspot:company:<id> (see HubSpot-linked customers). |
parent_account_id |
integer or null | Parent company when this customer is a division. |
qb_customer_id |
string or null | The company's QuickBooks Online customer id, when known. |
portal_url |
string or null | Customer portal link. Null until someone turns the portal on in TruTec. Read-only. |
mailing_address |
string or null | Mailing address (free text). |
billing_address |
string or null | Billing address (free text). Used as the bill-to address when TruTec creates the customer in QuickBooks Online. |
billing_contact_name |
string or null | Billing contact. |
billing_contact_email |
string or null | Billing contact email. |
billing_contact_phone |
string or null | Billing contact phone. |
created_at |
string | When the record was created (UTC). |
updated_at |
string | When the record last changed (UTC). |
The site object#
| Field | Type | Description |
|---|---|---|
id |
integer | TruTec id. Never changes. |
name |
string | Site name. Defaults to the address. |
address |
string | Street address. |
external_ref |
string or null | Your id for this site. Unique per workspace. |
client_account_id |
integer or null | The customer this site belongs to. |
created_at |
string | When the record was created (UTC). |
Coordinates are not on this object. The completed-sites feed (see Job costing) and the bulk export include latitude and longitude.
Customers#
List customers#
GET /v1/client-accounts
Permission: Read jobs
Returns the workspace's customers, sorted by name. Archived customers are not included.
| Name | Type | Required | Description |
|---|---|---|---|
q |
string | No | Only customers whose name contains this text (case-insensitive). |
external_ref |
string | No | Only the customer with this external_ref (0 or 1 rows). |
limit |
integer | No | Page size. Default 50, maximum 200. |
offset |
integer | No | Rows to skip. Default 0. |
curl "https://trutec.ai/api/v1/client-accounts?external_ref=crm-acct-20931" \
-H "X-API-Key: sk_your_api_key_here"
{
"data": [
{
"id": 4812,
"name": "Northgate Property Group",
"display_name": "Northgate Property Group",
"external_ref": "crm-acct-20931",
"parent_account_id": null,
"qb_customer_id": null,
"portal_url": null,
"mailing_address": "PO Box 2210, Austin, TX 78768",
"billing_address": "1200 Commerce Dr, Austin, TX 78701",
"billing_contact_name": "Accounts Payable",
"billing_contact_email": "[email protected]",
"billing_contact_phone": "512-555-0142",
"created_at": "2026-09-14T15:22:08.104512",
"updated_at": "2026-10-02T09:41:55.330019"
}
],
"pagination": { "total": 1, "limit": 50, "offset": 0 }
}
There is no updated_after filter on this endpoint. To pull only changed customers, use the bulk export's customers dataset (GET /v1/exports/customers.jsonl?updated_after=...).
Errors: 403 API key lacks job read permission.
Get a customer#
GET /v1/client-accounts/{id}
Permission: Read jobs
One customer by id, archived or not. Returns the customer object plus:
| Field | Type | Description |
|---|---|---|
assigned_user_id, assigned_user |
integer, object | The customer's salesperson ({id, name}). |
primary_contact_id |
integer or null | The customer's main person (see Contacts). |
notes |
string or null | The pinned note on the customer. |
default_customer_type, default_property_type |
string or null | Defaults new jobs for this customer pick up. |
lead_source |
string or null | Where the customer came from. |
contract_renewal_date |
string or null | YYYY-MM-DD. |
custom_fields |
object | Custom field values by key (see Custom fields). |
divisions |
array | {id, name} of its divisions. |
archived_at |
string or null | When it was archived. |
curl "https://trutec.ai/api/v1/client-accounts/4812" \
-H "X-API-Key: sk_your_api_key_here"
{
"data": {
"id": 4812,
"name": "Northgate Property Group",
"display_name": "Northgate Property Group",
"external_ref": "crm-acct-20931",
"parent_account_id": null,
"qb_customer_id": null,
"portal_url": null,
"mailing_address": "PO Box 2210, Austin, TX 78768",
"billing_address": "1200 Commerce Dr, Austin, TX 78701",
"billing_contact_name": "Accounts Payable",
"billing_contact_email": "[email protected]",
"billing_contact_phone": "512-555-0142",
"created_at": "2026-09-14T15:22:08.104512",
"updated_at": "2026-10-02T09:41:55.330019",
"assigned_user_id": 118,
"assigned_user": {"id": 118, "name": "Dana Whitfield"},
"primary_contact_id": 20417,
"notes": "Board meets the first Tuesday.",
"default_customer_type": "Property management",
"default_property_type": "Retail",
"lead_source": "Referral",
"contract_renewal_date": null,
"custom_fields": {"service_tier": "Gold"},
"divisions": [{"id": 4815, "name": "Northgate East"}],
"archived_at": null
}
}
Errors: 403 API key lacks job read permission; 404 Client account not found (also for another workspace's customer).
Create a customer#
POST /v1/client-accounts
Permission: Write jobs
Creates a customer, or returns the one that already matches. Safe to repeat.
TruTec looks for an existing customer in this order:
- one with the same
external_ref; - one with the same name (case and surrounding spaces ignored).
Archived customers count. When one matches, TruTec un-archives it, fills in a blank external_ref, fills in blank billing fields and sets the parent if it has none. It never overwrites a value the office already entered. The response is 200 with "created": false. Otherwise the customer is created and the response is 201 with "created": true.
Note
If the customer matched by name already holds a different external_ref, that ref is kept. Compare the returned external_ref with yours.
| Name | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | Company name. Max 200 characters. |
external_ref |
string | No | Your id. Max 120 characters. |
parent_account_id |
integer | No | Make this customer a division of that customer. |
parent_external_ref |
string | No | Same, by the parent's external_ref. |
mailing_address |
string | No | Max 1000 characters. |
billing_address |
string | No | Max 1000 characters. |
billing_contact_name |
string | No | Max 200 characters. |
billing_contact_email |
string | No | Max 200 characters. |
billing_contact_phone |
string | No | Max 80 characters. |
qb_customer_id |
string | No | QuickBooks Online customer id, if you already know it. Max 255 characters. |
Longer text values are cut to the maximum length. Divisions are one level deep: a parent cannot itself be a division. A division is created in QuickBooks Online as a sub-customer of its parent.
curl -X POST "https://trutec.ai/api/v1/client-accounts" \
-H "X-API-Key: sk_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"name": "Northgate Property Group",
"external_ref": "crm-acct-20931",
"billing_address": "1200 Commerce Dr, Austin, TX 78701",
"billing_contact_email": "[email protected]"
}'
{
"data": {
"id": 4812,
"name": "Northgate Property Group",
"display_name": "Northgate Property Group",
"external_ref": "crm-acct-20931",
"parent_account_id": null,
"qb_customer_id": null,
"portal_url": null,
"mailing_address": null,
"billing_address": "1200 Commerce Dr, Austin, TX 78701",
"billing_contact_name": null,
"billing_contact_email": "[email protected]",
"billing_contact_phone": null,
"created_at": "2026-10-12T14:03:22.418211",
"updated_at": "2026-10-12T14:03:22.418211"
},
"created": true
}
Errors:
400name is required400parent_account_id not found in this workspace/parent_external_ref not found in this workspace400parent is itself a division — divisions are one level deep409when two requests race to create the same customer. The body names the record that won:{"error": "external_ref is already used by another company in this workspace", "field": "external_ref", "conflicting_id": 4812, "conflicting_archived": false}. Retry and you get the existing record.
Update a customer#
PATCH /v1/client-accounts/{id}
Permission: Write jobs
Changes only the fields you send. Send null (or an empty string) to clear external_ref, a billing field or qb_customer_id. Clearing qb_customer_id makes the next QuickBooks Online sync look the customer up again.
| Name | Type | Required | Description |
|---|---|---|---|
id |
integer (path) | Yes | Customer id. |
name |
string | No | New name. Cannot be blank. |
external_ref |
string or null | No | Set or clear your id. |
parent_account_id |
integer or null | No | Set the parent, or null to make it a top-level customer. |
parent_external_ref |
string | No | Set the parent by its external_ref. |
mailing_address, billing_address, billing_contact_name, billing_contact_email, billing_contact_phone, qb_customer_id |
string or null | No | Same limits as on create. |
curl -X PATCH "https://trutec.ai/api/v1/client-accounts/4812" \
-H "X-API-Key: sk_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{"billing_contact_name": "Accounts Payable", "billing_contact_phone": "512-555-0142"}'
Returns {"data": {customer}}. When you send external_ref, the response also carries jobs_repointed: how many of this customer's jobs moved to a new HubSpot company (see HubSpot-linked customers).
Errors:
404Client account not found400name cannot be blank400an account cannot be its own parent/this account has divisions of its own and cannot become one/parent is itself a division — divisions are one level deep409when another customer (archived ones included) already holds thatexternal_refor name. Same body shape as above, withfieldset toexternal_reforname.
HubSpot-linked customers#
When a workspace connects HubSpot, TruTec links a customer to its HubSpot company through external_ref:
hubspot:company:<HubSpot company id>
For example hubspot:company:59033637717. Customers from older HubSpot job imports may still carry the short form hubspot:<id>; TruTec reads both. Jobs for a HubSpot customer also carry hubspot_company_id (the bare HubSpot id). That job field is read-only in the API.
After a HubSpot company merge. HubSpot gives the merged company a new id (the survivor) and retires the old ones. Repoint the TruTec customer right after the merge, so new jobs and deals use the survivor. First find the customer by the old id:
curl "https://trutec.ai/api/v1/client-accounts?external_ref=hubspot:company:7946463136" \
-H "X-API-Key: sk_your_api_key_here"
Then set the survivor id on it:
curl -X PATCH "https://trutec.ai/api/v1/client-accounts/4812" \
-H "X-API-Key: sk_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{"external_ref": "hubspot:company:59033637717"}'
{
"data": { "id": 4812, "external_ref": "hubspot:company:59033637717", "...": "..." },
"jobs_repointed": 3
}
TruTec then sets hubspot_company_id to the survivor id on this customer's jobs that still carry the old id, and reports how many in jobs_repointed. Jobs linked to any other HubSpot company, and other customers' jobs, are not touched. If both old companies were saved in TruTec as two customers, merge them in TruTec first (see Merge & search); a 409 means another customer already holds the survivor id.
HubSpot deals and their stage. In the "Lookup + create deals" HubSpot mode, TruTec creates one HubSpot deal for each new job, linked to the job's company, contacts and site, and by default moves the deal stage (and amount) each time the job moves stage. If your own HubSpot workflows own the deal stage, the workspace owner can turn on Settings → HubSpot → Deal stage: "TruTec sets it when it creates the deal, then leaves it to you". With it on, TruTec still sets the first stage and amount when it creates the deal (HubSpot needs a stage), then never changes dealstage on that deal again. The amount keeps following the job's price. The job keeps its link to the deal (hubspot_deal_id). This is a workspace setting in the app, not an API field.
Archive or delete a customer#
DELETE /v1/client-accounts/{id}
Permission: Write jobs and Delete records
By default this archives the customer. Archived customers drop out of GET lists but keep their data and links. A later POST with the same external_ref or name brings the customer back, and so does linking a job to it.
Add ?hard=true to delete the record permanently. TruTec refuses a hard delete while anything still points at the customer.
| Name | Type | Required | Description |
|---|---|---|---|
id |
integer (path) | Yes | Customer id. |
hard |
string | No | true to delete permanently instead of archiving. |
curl -X DELETE "https://trutec.ai/api/v1/client-accounts/4812" \
-H "X-API-Key: sk_your_api_key_here"
{ "archived": true, "id": 4812 }
A successful hard delete returns {"deleted": true, "id": 4812}.
Errors:
403API key lacks delete permission. "Delete records" is off by default on new keys.404client account not found403with"deletion_locked": truewhen the workspace has turned off deleting records. Archive instead.409when linked records exist:
{
"error": "account has linked records — archive instead, or detach them first",
"dependents": {
"jobs": 6, "locations": 2, "contacts": 3, "documents": 0,
"capital_plan_items": 0, "divisions": 0, "leads": 0, "activities": 11
}
}
A customer's contacts, jobs and sites#
GET /v1/client-accounts/{id}/contacts
GET /v1/client-accounts/{id}/jobs
GET /v1/client-accounts/{id}/locations
Permission: Read jobs
Paged lists (limit, default 50, max 200; offset) of what hangs off one customer.
- contacts: people whose primary company this is, or who have a current role there. Each row has
id,name,display_name,title,contact_type,email,phone,client_account_id,roles(role labels at this customer),is_primary_company,archived_atandupdated_at.include_archived=trueadds archived people. Full records:GET /v1/contacts/{id}(see Contacts). - jobs: the customer's jobs as job objects (see Jobs), newest first. Filters:
status,updated_after(then oldest change first),include_divisions=trueto add its divisions' jobs. - locations: sites the customer owns plus sites it is linked to in another role, as site detail objects (below) with
relationship:customerfor its own sites, otherwise the role (owner,property_manager,tenant,general_contractor,billing,other). Filters:q,include_archived.
curl "https://trutec.ai/api/v1/client-accounts/4812/jobs?status=won" \
-H "X-API-Key: sk_your_api_key_here"
Notes, history and follow-ups on a customer are in Notes & tasks.
Sites#
List sites#
GET /v1/locations
Permission: Read jobs
Returns the workspace's sites, sorted by name. Archived sites are not included.
| Name | Type | Required | Description |
|---|---|---|---|
q |
string | No | Only sites whose name or address contains this text (case-insensitive). |
external_ref |
string | No | Only the site with this external_ref. If two sites were merged in TruTec, the old site's ref still finds the surviving site. |
limit |
integer | No | Page size. Default 50, maximum 200. |
offset |
integer | No | Rows to skip. Default 0. |
curl "https://trutec.ai/api/v1/locations?q=Commerce%20Dr" \
-H "X-API-Key: sk_your_api_key_here"
{
"data": [
{
"id": 9307,
"name": "Northgate Plaza",
"address": "1200 Commerce Dr, Austin, TX 78701",
"external_ref": "crm-site-5512",
"client_account_id": 4812,
"created_at": "2026-09-14T15:23:40.512230"
}
],
"pagination": { "total": 1, "limit": 50, "offset": 0 }
}
There is no updated_after filter on this endpoint. To pull only changed sites, use the bulk export's properties dataset (GET /v1/exports/properties.jsonl?updated_after=...).
Errors: 403 API key lacks job read permission.
Get a site#
GET /v1/locations/{id}
Permission: Read jobs
One site by id, archived or not. Returns the site object plus client_account ({id, name}), property_type, notes, latitude, longitude, custom_fields, archived_at and updated_at.
{
"data": {
"id": 9307,
"name": "Northgate Plaza",
"address": "1200 Commerce Dr, Austin, TX 78701",
"external_ref": "crm-site-5512",
"client_account_id": 4812,
"created_at": "2026-09-14T15:23:40.512230",
"client_account": {"id": 4812, "name": "Northgate Property Group"},
"property_type": "Retail",
"notes": null,
"latitude": 30.2711,
"longitude": -97.7437,
"custom_fields": {},
"archived_at": null,
"updated_at": "2026-10-02T09:41:55.330019"
}
}
Errors: 404 Location not found.
A site's contacts and jobs#
GET /v1/locations/{id}/contacts
GET /v1/locations/{id}/jobs
Permission: Read jobs
People with a current role at the site (same row shape as a customer's contacts), and the jobs at the site (job objects; filters status and updated_after). Both are paged with limit and offset.
Create a site#
POST /v1/locations
Permission: Write jobs
Creates a site, or returns the one that already matches. Safe to repeat.
TruTec looks for an existing site in this order:
- one with the same
external_ref(or a site that absorbed that ref in a merge); - one at the same address. Addresses are compared in a normalized form, the same way the app matches jobs to sites.
Archived sites count. When one matches, TruTec un-archives it, fills in a blank external_ref, sets the customer if it has none and records the customer as related to the site. The response is 200 with "created": false. Otherwise the site is created and geocoded, and the response is 201 with "created": true.
| Name | Type | Required | Description |
|---|---|---|---|
address |
string | Yes | Street address. Max 500 characters. |
name |
string | No | Site name. Defaults to the address. Max 200 characters. |
external_ref |
string | No | Your id. Max 120 characters. |
client_account_id |
integer | No | The customer this site belongs to. |
Some workspaces require every site to have a customer. In that case creating a new site without client_account_id returns 422. A repeat POST that matches an existing site still succeeds.
curl -X POST "https://trutec.ai/api/v1/locations" \
-H "X-API-Key: sk_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"address": "1200 Commerce Dr, Austin, TX 78701",
"name": "Northgate Plaza",
"external_ref": "crm-site-5512",
"client_account_id": 4812
}'
{
"data": {
"id": 9307,
"name": "Northgate Plaza",
"address": "1200 Commerce Dr, Austin, TX 78701",
"external_ref": "crm-site-5512",
"client_account_id": 4812,
"created_at": "2026-10-12T14:05:10.004417"
},
"created": true
}
Errors:
400address is required400client_account_id not found422{"error": "This workspace requires: Company", "missing_fields": ["client_account_id"]}409when two requests race to create the same ref:{"error": "external_ref is already used by another location in this workspace", "field": "external_ref", "conflicting_id": 9307, "conflicting_archived": false}
Update a site#
PATCH /v1/locations/{id}
Permission: Write jobs
Changes only the fields you send. The address cannot be changed through the API.
| Name | Type | Required | Description |
|---|---|---|---|
id |
integer (path) | Yes | Site id. |
name |
string | No | New name. Cannot be blank. |
external_ref |
string or null | No | Set or clear your id. |
client_account_id |
integer or null | No | Set the customer, or null to clear it. |
curl -X PATCH "https://trutec.ai/api/v1/locations/9307" \
-H "X-API-Key: sk_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{"external_ref": "crm-site-5512"}'
Returns {"data": {site}}.
Errors:
404Location not found400name cannot be blank400client_account_id not found409when another site (archived ones included) already holds thatexternal_ref. The body includesconflicting_idandconflicting_archived, so you know which record to revive or clean up.
Archive or delete a site#
DELETE /v1/locations/{id}
Permission: Write jobs and Delete records
Works like customer delete. By default the site is archived: it drops out of GET lists and comes back if you POST the same external_ref or address. Add ?hard=true to delete it permanently, which TruTec refuses while jobs, assessments or other records point at it.
| Name | Type | Required | Description |
|---|---|---|---|
id |
integer (path) | Yes | Site id. |
hard |
string | No | true to delete permanently instead of archiving. |
curl -X DELETE "https://trutec.ai/api/v1/locations/9307?hard=true" \
-H "X-API-Key: sk_your_api_key_here"
{ "deleted": true, "id": 9307 }
Errors:
403API key lacks delete permission404location not found403with"deletion_locked": truewhen the workspace has turned off deleting records.409location has linked records — archive instead, or detach them first, withdependentscounts forjobs,assessments,capital_plan_itemsandowner_properties.
Re-bids#
Recurring work coming due at properties you've served: sealcoat every 2 years, stripe every year, and so on. TruTec builds each property's service history from your won jobs (plus history entered by hand in the app) and works out when each service is due again. To create re-bid jobs, snooze or decline re-bids and add past work by hand, see Re-bids, reminders & My day. To merge duplicate customers or sites, see Merge & search.
List re-bid services#
GET /v1/rebid-cycles
Permission: Read jobs
The services the workspace re-bids: id, name, interval_months, remind_days (how early it shows as due), job_types and keywords (how past work is recognized), default_stage, is_active, position.
List re-bids#
GET /v1/rebids
Permission: Read jobs
One row per property and service, soonest due first.
| Name | Type | Required | Description |
|---|---|---|---|
location_id |
integer | No | One site. |
client_account_id |
integer | No | One customer's sites. |
cycle_id |
integer | No | One service. |
status |
string | No | not_started, created (a re-bid job exists), snoozed or not_doing. |
tab |
string | No | due (overdue or inside its heads-up window), upcoming, done or lapsed (more than a year overdue). |
due_before, due_after |
string | No | YYYY-MM-DD. |
limit, offset |
integer | No | Paging. |
{
"data": [
{
"key": "loc:9307",
"cycle_id": 12,
"service": "Sealcoating",
"location_id": 9307,
"property_name": "Northgate Plaza",
"address": "1200 Commerce Dr, Austin, TX 78701",
"client_account_id": 4812,
"client_account_name": "Northgate Property Group",
"salesperson_user_id": 118,
"last_done_on": "2024-06-01",
"last_done_source": "job end date",
"last_amount": 8200.0,
"last_job_id": 38110,
"due_on": "2026-06-01",
"days_until_due": -133,
"bucket": "overdue",
"tab": "due",
"status": "not_started",
"status_label": "Not started",
"rebid_job_id": null,
"snooze_until": null,
"not_doing_reason": null,
"not_doing_note": null,
"follow_up_due": null
}
],
"pagination": {"total": 1, "limit": 50, "offset": 0}
}
key identifies the property: loc:<site id> for a saved site, otherwise addr:<normalized address> for jobs that only carry an address.
Errors: 400 for a bad filter; 404 for a site or customer outside the workspace.