# Customers & sites

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:

```json
{
  "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](#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. |

```bash
curl "https://trutec.ai/api/v1/client-accounts?external_ref=crm-acct-20931" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "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": "ap@northgate.example",
      "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](contacts.md)). |
| `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](custom-fields.md)). |
| `divisions` | array | `{id, name}` of its divisions. |
| `archived_at` | string or null | When it was archived. |

```bash
curl "https://trutec.ai/api/v1/client-accounts/4812" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "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": "ap@northgate.example",
    "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.

```bash
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": "ap@northgate.example"
  }'
```

```json
{
  "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": "ap@northgate.example",
    "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. |

```bash
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](#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:

```bash
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:

```bash
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"}'
```

```json
{
  "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](merge-and-search.md)); 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. |

```bash
curl -X DELETE "https://trutec.ai/api/v1/client-accounts/4812" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{ "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:

```json
{
  "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](contacts.md)).
- **jobs:** the customer's jobs as job objects (see [Jobs](jobs.md)), 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`.

```bash
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](notes-and-tasks.md).

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

```bash
curl "https://trutec.ai/api/v1/locations?q=Commerce%20Dr" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "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`.

```json
{
  "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.

```bash
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
  }'
```

```json
{
  "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. |

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

```bash
curl -X DELETE "https://trutec.ai/api/v1/locations/9307?hard=true" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{ "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](rebids-and-reminders.md). To merge duplicate customers or sites, see [Merge & search](merge-and-search.md).

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

```json
{
  "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.
