Skip to content
TruTecDevelopers
Get an API key

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 Markdown

Customers 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_id and location_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:

  1. one with the same external_ref;
  2. 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:

  • 400 name is required
  • 400 parent_account_id not found in this workspace / parent_external_ref not found in this workspace
  • 400 parent is itself a division — divisions are one level deep
  • 409 when 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:

  • 404 Client account not found
  • 400 name cannot be blank
  • 400 an 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 deep
  • 409 when another customer (archived ones included) already holds that external_ref or name. Same body shape as above, with field set to external_ref or name.

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:

  • 403 API key lacks delete permission. "Delete records" is off by default on new keys.
  • 404 client account not found
  • 403 with "deletion_locked": true when the workspace has turned off deleting records. Archive instead.
  • 409 when 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_at and updated_at. include_archived=true adds 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=true to 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: customer for 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:

  1. one with the same external_ref (or a site that absorbed that ref in a merge);
  2. 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:

  • 400 address is required
  • 400 client_account_id not found
  • 422 {"error": "This workspace requires: Company", "missing_fields": ["client_account_id"]}
  • 409 when 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:

  • 404 Location not found
  • 400 name cannot be blank
  • 400 client_account_id not found
  • 409 when another site (archived ones included) already holds that external_ref. The body includes conflicting_id and conflicting_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:

  • 403 API key lacks delete permission
  • 404 location not found
  • 403 with "deletion_locked": true when the workspace has turned off deleting records.
  • 409 location has linked records — archive instead, or detach them first, with dependents counts for jobs, assessments, capital_plan_items and owner_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.