# Measure a site

One request measures one site. You tell TruTec where the site is, TruTec measures it from aerial imagery, and you get back:

- the paved area, as outlines and a total (square feet, square yards, square meters or acres);
- stalls, handicap stalls, truck stalls, arrows and painted symbols, as counts with a point for each item;
- crosswalks, hatching and stop bars, as area, count and estimated paint linear feet;
- white, yellow and double yellow striping, as linear feet with a centerline for each stripe;
- optionally carports, concrete and sidewalks;
- optionally curbs (linear feet), wheel stops, speed bumps, bollards and parallel stalls (counts), and landscaping (lawn, beds and other areas, plus tree counts). See [Add-on models](#add-on-models).

Quantities are AI estimates from imagery. They are the same numbers the TruTec takeoff editor shows for the same area before anyone edits them.

All examples use the base URL `https://trutec.ai/api/v1` and send your key in the `X-API-Key` header. Call the API from your server only. Never put a key in browser or mobile code.

## Choose how to describe the site

| You have | Send | Endpoint |
|---|---|---|
| A street address | `address` | `POST /v1/detect/address` |
| A rectangle | `bounds` (`north`, `south`, `east`, `west`) | `POST /v1/detect/full` |
| A property outline | `polygon` (one ring of `[lat, lng]` points) | `POST /v1/detect/full` |
| An outline with islands to leave out | `polygon` as `[boundary, island, ...]`, or `exclude` | `POST /v1/detect/full` |
| Several sections to quote separately | `areas` (up to 20 named outlines) | `POST /v1/detect/full` |
| Property parcels you picked from [`GET /v1/parcels`](#find-parcels) | `parcel_ids` with the same `address`, or `lat` and `lng` | `POST /v1/detect/full` |
| Your own north-up aerial images | `tiles` (base64 images with bounds) | `POST /v1/detect/tiles` |

Coordinates in requests and JSON responses are always `[latitude, longitude]`. GeoJSON output uses `[longitude, latitude]`, as the GeoJSON standard requires.

`bounds` decides which imagery is fetched. `polygon` and `areas` decide what is counted: only items inside the outline are kept. If you send only a `polygon` or `areas`, TruTec works out the bounds for you. Send your own `bounds` with a `polygon` only when you want extra imagery around the outline. To leave ground inside the outline out, such as landscaped islands, see [Excluded islands](#excluded-islands).

## Sync or async

Detection usually takes 30 to 120 seconds, depending on the size of the site.

- **Synchronous (default).** The request stays open and returns the result. A synchronous request has to finish in about 100 seconds. Past that, the connection can close before the result arrives and you get a non-JSON 5xx error, even though the work still ran and still counts as a detection. Use synchronous calls for small sites and testing.
- **Asynchronous (`"async": true`).** The request returns `202` with a job `id` right away. Poll `GET /v1/detect/status/{id}` every 5 to 10 seconds until `status` is `completed` or `failed`. Use this in production and for anything larger than a single small lot.

The async result is exactly the body a synchronous call would return. It stays available from the status endpoint, so you can fetch it again with the same id. TruTec does not publish a retention period for job results, so save the result in your own database when the job completes. Async works with `json` and `geojson` output, not `kmz`, and is not available on `/detect/tiles`.

## Endpoints

### Measure a site by coordinates

`POST /v1/detect/full`

**Permission:** Read measurements

Measures the area inside `bounds`, `polygon`, `areas` or the parcels in `parcel_ids`. Send at least one of the four. The request body is JSON.

| Name | Type | Required | Description |
|---|---|---|---|
| `bounds` | object | One of `bounds`, `polygon`, `areas`, `parcel_ids` | `{"north", "south", "east", "west"}` in decimal degrees. All four keys are required. |
| `polygon` | array | One of `bounds`, `polygon`, `areas`, `parcel_ids` | One outline of at least 3 `[lat, lng]` number pairs. You don't need to repeat the first point. Only items inside it are counted. To leave islands out, send a list of rings instead: `[boundary, island, ...]`. See [Excluded islands](#excluded-islands). |
| `areas` | array | One of `bounds`, `polygon`, `areas`, `parcel_ids` | 1 to 20 objects, each `{"name": "North lot", "polygon": [[lat, lng], ...]}`. See [Named areas](#named-areas). Don't send with `polygon`. |
| `exclude` | array | No | Islands to leave out of the `polygon` or the `areas`: a list of rings, each at least 3 `[lat, lng]` points. See [Excluded islands](#excluded-islands). |
| `parcel_ids` | array of strings | One of `bounds`, `polygon`, `areas`, `parcel_ids` | 1 to 10 parcel `id`s from [`GET /v1/parcels`](#find-parcels). Send with `address`, or `lat` and `lng`, and not with `bounds`, `polygon` or `areas`. See [Measure by parcel](#measure-by-parcel). |
| `address` | string | With `parcel_ids` | The address you searched `GET /v1/parcels` with. Ignored without `parcel_ids`. |
| `lat`, `lng` | number | With `parcel_ids` | The point you searched `GET /v1/parcels` with, instead of `address`. Ignored without `parcel_ids`. |
| `unit` | string | No | Area unit: `sf` (default), `sy`, `sm` or `acres`. Linear quantities are always in feet. An unknown value falls back to `sf`. |
| `format` | string | No | `json` (default), `geojson` or `kmz`. An unknown value falls back to `json`. |
| `async` | boolean | No | `true` returns a job id instead of waiting. Default `false`. |
| `include_parking` | boolean | No | Measure paved area. Default `true`. |
| `include_objects` | boolean | No | Count stalls, striping and markings. Defaults to the value of `include_parking`. When paved area is also measured, markings are counted only inside the detected pavement. |
| `include_carports` | boolean | No | Measure carports and estimate the stalls under them. Default `false`. |
| `include_concrete` | boolean | No | Measure concrete: any ground-level concrete the model sees (concrete paving and drive aisles, aprons, pads, walks and other flatwork), by material, not by use. Default `false`. |
| `include_sidewalk` | boolean | No | Measure sidewalks. Where concrete and sidewalk overlap, the area counts as concrete. Default `false`. |
| `asphalt_only` | boolean | No | Cut the concrete (and, with `include_sidewalk`, the sidewalk) out of the paved area, so `parking_areas` and `summary.total_area` are asphalt only. Turns on `include_concrete`. Markings are still counted where they are. Adds `asphalt_only.removed_sqft`. Default `false`. |
| `include_curbs` | boolean | No | Find curbs: linear feet and a line for each curb. Default `false`. See [Add-on models](#add-on-models). |
| `include_wheel_stops` | boolean | No | Count wheel stops, with a point for each. Default `false`. |
| `include_site_objects` | boolean | No | Count speed bumps, bollards and parallel parking stalls, by type, with a point for each. Default `false`. |
| `include_landscape` | boolean | No | Measure landscaping: area by type (lawn, mulch beds, gravel, hedges, ponds and more) and counts of trees, shrubs and other landscape objects. Default `false`. |
| `marking_details` | boolean | No | Also read paint color, arrow direction, stencil text, wheelchair symbols, stall divider style (single or double line) and row end lines. Adds about 5 to 15 seconds. Must be a JSON `true` or `false`. Ignored for `kmz`. See [Marking details](measurement-reference.md#marking-details). |
| `nearmap_survey_id` | string | No | Measure on a specific Nearmap survey instead of the default imagery. See [Imagery](#imagery). |
| `nearmap_date` | string | No | The survey's capture date (`YYYY-MM-DD`). TruTec copies it into `imagery_source.date` in the response. |
| `counter_display` | object | No | Which value each category shows in its top-level `value`/`formatted`, e.g. `{"crosswalks": "linear_feet"}`. Allowed values per category are in [`GET /v1/categories`](measurement-reference.md#list-categories). |
| `counter_labels` | object | No | Rename categories in this response, e.g. `{"abc": "Stencils"}`. Labels are trimmed to 80 characters. |

Your workspace's measurement settings (labels, display choices, stall widths, divider styles) apply automatically. Fields in the request override them for that request only.

```bash
curl https://trutec.ai/api/v1/detect/full \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "polygon": [
      [30.40112, -97.72231],
      [30.40109, -97.72058],
      [30.39985, -97.72061],
      [30.39988, -97.72236]
    ],
    "unit": "sf"
  }'
```

A trimmed synchronous response:

```json
{
  "success": true,
  "summary": {
    "total_area": {"value": 186420.37, "unit": "SF", "formatted": "186,420 SF"},
    "total_area_sqft": 186420,
    "total_stalls": 412,
    "polygon_count": 3,
    "unit": "sf",
    "phase": "Phase 1"
  },
  "parking_areas": [
    {
      "coordinates": [[30.40104, -97.72225], [30.40101, -97.72066], [30.39993, -97.72068], [30.39996, -97.72229]],
      "holes": [],
      "rings": [[[30.40104, -97.72225], [30.40101, -97.72066], [30.39993, -97.72068], [30.39996, -97.72229]]],
      "area_sqft": 154210.4,
      "type": "parking_area"
    }
  ],
  "counters": {
    "stalls": {
      "label": "Stalls",
      "count": 398,
      "unit": "count",
      "formatted": "398",
      "points": [{"id": "stalls-0", "lat": 30.40087, "lng": -97.72201, "confidence": 0.94, "area_sqft": 161.8}],
      "values": {
        "count": {"value": 398, "formatted": "398"},
        "linear_feet": {"value": 7104.0, "unit": "LF", "formatted": "7,104 LF", "breakdown": {"row_divider_lf": 6678.0, "center_spine_lf": 426.0}}
      }
    },
    "lines_white": {
      "label": "White Lines",
      "value": 2846.3,
      "unit": "LF",
      "formatted": "2,846 LF",
      "lines": [{"id": "lines_white-0", "points": [[30.40061, -97.72190], [30.40061, -97.72121]], "length_ft": 218.7, "phase": "Phase 1",
                 "overlap_with_stalls_ft": 0.0, "counted_length_ft": 218.7, "overlap_stall_ids": []}]
    }
  },
  "counter_summary": {
    "stalls": {"label": "Stalls", "count": 398, "formatted": "398", "linear_feet": 7104, "line_count": 371},
    "crosswalks": {"label": "Crosswalks", "count": 6, "formatted": "1,285 SF", "area_sqft": 1285, "linear_feet": 1046},
    "lines_white": {"label": "White Lines", "count": 214, "formatted": "2,846 LF", "linear_feet": 2846}
  },
  "imagery_source": {"type": "google"}
}
```

The real response has more fields, including the full `values` block on every `counter_summary` entry. [Measurement result reference](measurement-reference.md) lists every field.

**Notable errors**

| Status | `error` |
|---|---|
| 400 | `Request body required` |
| 400 | `Either bounds or polygon is required` |
| 400 | `Missing bounds.north` (or `south`, `east`, `west`) |
| 400 | `polygon must be a ring of [lat, lng] points, or a list of rings [boundary, island, ...] (send one boundary per request, or use areas for multiple named areas)` |
| 400 | `Send either polygon or areas, not both.` |
| 400 | An island that is outside its boundary, malformed, or covers the whole boundary. See [Excluded islands](#excluded-islands). |
| 400 | `parcel_ids must be a list of 1 to 10 parcel ids from GET /v1/parcels` |
| 400 | `Parcel {id} not found near that address` (or `near that point`; also returns `parcel_id`) |
| 400 | `Send parcel_ids with an address or lat and lng, not with polygon, bounds or areas` |
| 400 | `Detection area too large (1250 acres). Maximum is 1000 acres.` (also returns `area_acres` and `max_acres`) |
| 400 | `Must include at least one detection type` |
| 400 | `marking_details must be true or false.` |
| 400 | `include_curbs must be true or false.` (same for `include_wheel_stops`, `include_site_objects`, `include_landscape`) |
| 400 | `include_curbs, include_wheel_stops, include_site_objects and include_landscape work with json and geojson output, not kmz.` |
| 400 | `Async mode does not support KMZ format. Use "json" or "geojson".` |
| 403 | `API key lacks detection permission` |
| 403 | `Landscape detection is turned off for this key` (`include_landscape` only; see [Add-on models](#add-on-models)) |
| 500 | The detection could not be completed. `error` describes what failed. |

### Measure a site by address

`POST /v1/detect/address`

**Permission:** Read measurements

TruTec geocodes the address with Google, finds the property parcel at that point from public parcel records, and measures inside the parcel outline. The response is the same as `/detect/full`.

Takes the same options as `/detect/full` (`unit`, `format`, `async`, the `include_*` flags, `marking_details`, `nearmap_survey_id`, `nearmap_date`, `counter_display`, `counter_labels`), plus:

| Name | Type | Required | Description |
|---|---|---|---|
| `address` | string | Yes | Street address, e.g. `1200 Commerce Dr, Austin, TX 78758`. |
| `parcel_index` | integer | No | Which nearby parcel to measure. Parcels are sorted nearest-first from the geocoded point. Default `0` (the nearest). |
| `parcel_ids` | array of strings | No | Measure these parcels from [`GET /v1/parcels`](#find-parcels) (searched with the same `address`) instead of one picked by `parcel_index`. Don't send with `parcel_index`. See [Measure by parcel](#measure-by-parcel). |
| `parcel_radius` | integer | No | How far around the geocoded point to look for parcels, in meters. Default `200`. |
| `parcel_limit` | integer | No | Most parcels to consider. Default `25`. |

The parcel outline replaces any `polygon` or `bounds` you send. `areas` is not accepted here; send named areas to `/detect/full`. To leave islands inside the parcel out, send them in `exclude` (see [Excluded islands](#excluded-islands)).

```bash
curl https://trutec.ai/api/v1/detect/address \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"address": "1200 Commerce Dr, Austin, TX 78758", "unit": "sy", "async": true}'
```

```json
{
  "id": "6f1c2a8e-41d7-4b0e-9a55-2f3d8c7e9b10",
  "status": "pending",
  "poll_url": "/api/v1/detect/status/6f1c2a8e-41d7-4b0e-9a55-2f3d8c7e9b10",
  "billing": null
}
```

The response does not repeat the matched address or parcel. `context.map_view.bounds` in the result shows the box that was measured. If the parcel could be wrong (for example, a corner lot split across two parcels), list the parcels first with [`GET /v1/parcels`](#find-parcels) and send the ones you want as `parcel_ids`.

**Notable errors**

| Status | `error` |
|---|---|
| 400 | `address is required` |
| 400 | `areas is supported on /api/v1/detect/full; send the areas there instead of an address.` |
| 400 | `parcel_radius, parcel_limit, and parcel_index must be integers` |
| 400 | `parcel_index is out of range for the resolved address` (also returns `parcel_count`) |
| 404 | `Address not found` |
| 404 | `No parcel found for address` |
| 422 | `Resolved parcel does not contain usable polygon geometry` |
| 502 | The address or parcel lookup failed. Retry later. |

With `parcel_ids`, the errors are the ones listed for `parcel_ids` under `/detect/full`, plus `400 Send parcel_ids or parcel_index, not both`.

### Find parcels

`GET /v1/parcels`

**Permission:** Read measurements

Lists the property parcels near an address or a point, nearest first, from public parcel records. Each parcel has an `id` to measure it with ([Measure by parcel](#measure-by-parcel)). Finding parcels is not billed.

| Name | Type | Required | Description |
|---|---|---|---|
| `address` | string (query) | `address`, or `lat` and `lng` | Street address, e.g. `1200 Commerce Dr, Austin, TX 78758`. |
| `lat`, `lng` | number (query) | `address`, or `lat` and `lng` | A point in decimal degrees, instead of `address`. |
| `radius` | integer (query) | No | How far around the location to look, in meters. 1 to 500, default `150`. |
| `limit` | integer (query) | No | Most parcels to return. 1 to 25, default `10`. |
| `include_outline` | boolean (query) | No | `true` adds each parcel's `outline`. Default `false`. |

```bash
curl "https://trutec.ai/api/v1/parcels?address=1200%20Commerce%20Dr%2C%20Austin%2C%20TX%2078758&limit=3" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "location": {"lat": 30.40052, "lng": -97.72147, "address": "1200 Commerce Dr, Austin, TX 78758, USA"},
  "parcels": [
    {
      "id": "2f6a1c7e-90b4-4d1e-8f3a-5b2c7d9e0a14",
      "address": "1200 COMMERCE DR",
      "owner": "NORTHGATE PROPERTIES LLC",
      "apn": "0245170104",
      "acres": 4.812,
      "distance_m": 0,
      "center": [30.40049, -97.72146]
    },
    {
      "id": "8c3d5e1f-2a7b-4c90-b6d4-1e9f0a3b7c25",
      "address": "1220 COMMERCE DR",
      "owner": "NORTHGATE PROPERTIES LLC",
      "apn": "0245170105",
      "acres": 1.906,
      "distance_m": 0,
      "center": [30.39968, -97.72150]
    }
  ]
}
```

| Field | What it is |
|---|---|
| `location` | The point searched around: your `lat`/`lng`, or the geocoded address with the `address` it matched. |
| `parcels[].id` | The parcel's id. Send it in `parcel_ids` to measure the parcel. |
| `address`, `owner`, `apn` | The parcel's site address, owner and assessor's parcel number from public records. Any of them can be `null`. |
| `acres` | The parcel's area in acres. |
| `distance_m` | Meters from the location to the parcel's edge. `0` means the location is inside the parcel. |
| `center` | A `[lat, lng]` point inside the parcel. |
| `outline` | With `include_outline=true`: the boundary as `[lat, lng]` points. For a parcel in several pieces, the largest piece. |

No parcels within `radius` returns `200` with an empty `parcels` list.

**Notable errors**

| Status | `error` |
|---|---|
| 400 | `Location required` |
| 400 | `Send address, or lat and lng, not both` |
| 400 | `radius must be a whole number from 1 to 500` (and the same for `limit`, 1 to 25) |
| 403 | `API key lacks measurement read permission` |
| 404 | `Address not found` |
| 502 | The address or parcel lookup failed. Retry later. |
| 503 | `Parcel lookup unavailable` |

### Measure your own imagery

`POST /v1/detect/tiles`

**Permission:** Read measurements

Runs the same detection on images you upload instead of imagery TruTec fetches. Use it when you already license imagery, such as drone orthomosaics. TruTec does not fetch any imagery for this call and does not store your images. The call is synchronous only.

| Name | Type | Required | Description |
|---|---|---|---|
| `image_crs` | string | Yes | Must be `EPSG:3857`. Images must be north-up Web Mercator. |
| `tiles` | array | Yes | 1 to 32 objects, each `{"image_base64": "...", "bounds": {"north", "south", "east", "west"}}`. `bounds` are WGS84 degrees for the image's outer edges. Plain base64 only (no `data:` prefix, no URLs). |
| `include_parking` | boolean | No | Default `true`. |
| `include_objects` | boolean | No | Defaults to the value of `include_parking`. Enable at least one of the two. With `include_parking: false`, markings are counted across the whole upload. |
| `unit` | string | No | `sf` (default) or `sy`. |
| `format` | string | No | `json` (default) or `geojson`. |
| `marking_details` | boolean | No | As on `/detect/full`. |

Image rules:

- Opaque PNG or JPEG, one frame, no alpha channel, no EXIF rotation, 16 to 4,096 pixels per edge.
- At most 8 MiB of decoded images and 8,388,608 pixels in total, and at most 12 MiB for the whole JSON body.
- Tiles must form one rectangle with no gaps or overlaps.
- Pixel scale must match standard XYZ zoom 19 to 22, with square pixels. Zoom 20 is preferred. At 30° latitude, zoom 20 is about 13 cm per pixel.
- Very large coverages are refused with `Coverage is too large for one synchronous request; send smaller rectangles.`

Only the fields above are accepted. Carports, concrete, sidewalk, the add-on models, `polygon`, `areas`, `kmz` and `async` are not. Detection outside the edges of your images is not possible, so include a margin around the site. The response's `imagery_source` describes the coverage you sent.

```bash
curl https://trutec.ai/api/v1/detect/tiles \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  --data-binary @tile-request.json
```

**Notable errors**

| Status | `error` |
|---|---|
| 400 | `image_crs must be EPSG:3857; supply north-up Web Mercator images.` |
| 400 | `Tiles must form one rectangle without gaps or overlaps.` |
| 400 | `Pixel scale must match standard XYZ zoom 19–22, with square Web Mercator pixels; zoom 20 is preferred.` |
| 400 | `Unsupported fields for tile inference. Use uploaded tiles with parking/object detection options.` |
| 400 | `Tile inference currently supports synchronous requests only.` |
| 413 | `Tile request exceeds 12 MiB.` |
| 415 | `Content-Type must be application/json` |

### Get an async result

`GET /v1/detect/status/{job_id}`

**Permission:** Any key on the workspace that started the job

| Name | Type | Required | Description |
|---|---|---|---|
| `job_id` | string (path) | Yes | The `id` returned by the `202` response. |

`status` is `pending`, then `processing`, then `completed` or `failed`. Polling is free; it does not count as a detection.

```bash
curl https://trutec.ai/api/v1/detect/status/6f1c2a8e-41d7-4b0e-9a55-2f3d8c7e9b10 \
  -H "X-API-Key: sk_your_api_key_here"
```

While running:

```json
{"id": "6f1c2a8e-41d7-4b0e-9a55-2f3d8c7e9b10", "status": "processing", "created_at": "2026-10-12T15:04:11.204Z"}
```

When done, `result` holds the full detection response:

```json
{
  "id": "6f1c2a8e-41d7-4b0e-9a55-2f3d8c7e9b10",
  "status": "completed",
  "created_at": "2026-10-12T15:04:11.204Z",
  "completed_at": "2026-10-12T15:05:02.877Z",
  "result": {"success": true, "summary": {"total_area": {"value": 20713.37, "unit": "SY", "formatted": "20,713 SY"}, "total_stalls": 412}}
}
```

If it failed, `error` says why:

```json
{"id": "6f1c2a8e-41d7-4b0e-9a55-2f3d8c7e9b10", "status": "failed", "created_at": "2026-10-12T15:04:11.204Z", "completed_at": "2026-10-12T15:04:40.120Z", "error": "Parking detection failed"}
```

Returns `404 {"error": "Detection job not found"}` for an unknown id or a job from another workspace. Set your own overall limit on polling (for example 10 minutes) and treat a job that runs past it as failed.

## Full example

This measures a property outline asynchronously, waits for the result and prints each quantity.

### curl

```bash
# 1. Start the job
curl https://trutec.ai/api/v1/detect/full \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "polygon": [[30.40112, -97.72231], [30.40109, -97.72058], [30.39985, -97.72061], [30.39988, -97.72236]],
    "unit": "sf",
    "async": true
  }'
# 202 {"id": "6f1c2a8e-...", "status": "pending", "poll_url": "/api/v1/detect/status/6f1c2a8e-...", "billing": null}

# 2. Poll every 5-10 seconds until status is "completed" or "failed"
curl https://trutec.ai/api/v1/detect/status/6f1c2a8e-41d7-4b0e-9a55-2f3d8c7e9b10 \
  -H "X-API-Key: sk_your_api_key_here"
```

### Python

Uses the `requests` package.

```python
import os
import time

import requests

BASE_URL = "https://trutec.ai/api/v1"
HEADERS = {"X-API-Key": os.environ["TRUTEC_API_KEY"]}


def measure_site(payload, poll_every=5, give_up_after=600):
    """Start an async detection and wait for it. Returns (job_id, result)."""
    start = requests.post(
        f"{BASE_URL}/detect/full",
        headers=HEADERS,
        json={**payload, "async": True},
        timeout=30,
    )
    if start.status_code != 202:
        raise RuntimeError(f"Detection not started ({start.status_code}): {start.text}")
    job_id = start.json()["id"]

    deadline = time.monotonic() + give_up_after
    while time.monotonic() < deadline:
        time.sleep(poll_every)
        job = requests.get(
            f"{BASE_URL}/detect/status/{job_id}", headers=HEADERS, timeout=30
        ).json()
        if job["status"] == "completed":
            return job_id, job["result"]
        if job["status"] == "failed":
            raise RuntimeError(job.get("error") or "Detection failed")
    raise TimeoutError(f"Job {job_id} did not finish in time")


job_id, result = measure_site({
    "polygon": [
        [30.40112, -97.72231],
        [30.40109, -97.72058],
        [30.39985, -97.72061],
        [30.39988, -97.72236],
    ],
    "unit": "sf",
})

# Keep job_id and the raw result in your own database.
print("Paved area:", result["summary"]["total_area"]["formatted"])
print("Total stalls:", result["summary"]["total_stalls"])
for category, entry in result["counter_summary"].items():
    values = entry["values"]
    lf = values.get("linear_feet", {}).get("formatted")
    print(f"{entry['label']}: {entry['formatted']}" + (f" ({lf})" if lf else ""))
```

### Node.js

Node 18 or later (built-in `fetch`), as an ES module. Run it on your server.

```javascript
const BASE_URL = "https://trutec.ai/api/v1";
const headers = {
  "X-API-Key": process.env.TRUTEC_API_KEY,
  "Content-Type": "application/json",
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function measureSite(payload, { pollMs = 5000, giveUpMs = 600_000 } = {}) {
  const start = await fetch(`${BASE_URL}/detect/full`, {
    method: "POST",
    headers,
    body: JSON.stringify({ ...payload, async: true }),
    signal: AbortSignal.timeout(30_000),
  });
  if (start.status !== 202) {
    throw new Error(`Detection not started (${start.status}): ${await start.text()}`);
  }
  const { id } = await start.json();

  const deadline = Date.now() + giveUpMs;
  while (Date.now() < deadline) {
    await sleep(pollMs);
    const res = await fetch(`${BASE_URL}/detect/status/${id}`, {
      headers,
      signal: AbortSignal.timeout(30_000),
    });
    const job = await res.json();
    if (job.status === "completed") return { jobId: id, result: job.result };
    if (job.status === "failed") throw new Error(job.error || "Detection failed");
  }
  throw new Error(`Job ${id} did not finish in time`);
}

const { jobId, result } = await measureSite({
  polygon: [
    [30.40112, -97.72231],
    [30.40109, -97.72058],
    [30.39985, -97.72061],
    [30.39988, -97.72236],
  ],
  unit: "sf",
});

console.log("Paved area:", result.summary.total_area.formatted);
for (const entry of Object.values(result.counter_summary)) {
  console.log(`${entry.label}: ${entry.formatted}`);
}
```

## Read the result

The fields most integrations need:

| Field | What it is |
|---|---|
| `summary.total_area` | Total paved area in your `unit` (`value`, `unit`, `formatted`). Includes carport area when carports are on. Leaves out concrete and sidewalk with `asphalt_only`. |
| `summary.total_area_sqft` | The same total in square feet, rounded. |
| `summary.total_stalls` | Stalls + handicap + truck stalls. |
| `parking_areas[]` | Each paved area as rings of `[lat, lng]` (`rings[0]` is the outline, the rest are holes such as islands) with `area_sqft`. |
| `counter_summary.<category>` | One entry per category with every quantity: `values.count`, `values.area`, `values.linear_feet`, `values.line_count`, whichever apply. Use this to store quantities. |
| `counters.<category>` | The same categories with geometry: `points[]`, `polygons[]` or `lines[]`, each item with an `id`. Use this to draw or audit items. |
| `imagery_source` | Which imagery was measured. See [Imagery](#imagery). |

Units:

- **Areas** (`total_area`, `values.area`) are in the `unit` you asked for: `SF`, `SY`, `SM` or `AC`.
- **Linear quantities** (`values.linear_feet`, `length_ft`) are always feet (`LF`). Every category's `values.linear_feet.value` adds to the others without counting paint twice: a stripe lying on a stall divider or center line is counted under the stall category, not striping. See [Adding up linear feet](measurement-reference.md#adding-up-linear-feet).
- **Fields ending in `_sqft`** are always square feet, whatever `unit` you asked for.
- **Counts** have no unit.

Category keys are stable: `stalls`, `handicap`, `trucks`, `arrows`, `abc` (painted symbols), `crosswalks`, `crosshatches`, `stop_line`, `lines_white`, `lines_yellow`, `lines_double_yellow`. A category with nothing detected is left out. New categories can appear over time, so handle unknown keys generically. [`GET /v1/categories`](measurement-reference.md#list-categories) returns labels, groups and the measurement types each category supports.

## Named areas

Send `areas` instead of `polygon` to measure several sections in one request, for example a north lot and a south lot that you price separately. It is one detection.

```json
{
  "areas": [
    {"name": "North lot", "polygon": [[30.40112, -97.72231], [30.40109, -97.72058], [30.40048, -97.72059], [30.40050, -97.72233]]},
    {"name": "South lot", "polygon": [[30.40050, -97.72233], [30.40048, -97.72059], [30.39985, -97.72061], [30.39988, -97.72236]]}
  ],
  "unit": "sf"
}
```

- Up to 20 areas. Each `name` must be unique, non-empty and at most 64 characters. Each `polygon` follows the same rules as `polygon`, with at most 2,000 points, including `[boundary, island, ...]` rings to leave islands out of that area (see [Excluded islands](#excluded-islands)).
- Every item is counted once. Where areas overlap, the first area listed gets the item. Items outside every area are dropped, except a marking within about 1.5 m (5 ft) of an area's edge, which goes to that area. A marking inside an excluded island is never pulled into the area around it.
- Stripes and pavement that cross an area edge are split, so each area gets the length or area inside it.
- `bounds` is ignored and worked out from all the areas together. That combined box must be 1,000 acres or less.

The response adds `areas[]`, one entry per area: `name`, `area_sqft` (pavement inside the area, when paved area is measured) and its own `counter_summary`. Every item in `counters` gets `area` and `phase` set to its area's name. The areas add up to the request totals.

## Excluded islands

Some boundaries have ground inside them that is not part of the job, such as landscaped islands, planters or a building pad. There are three ways to leave it out:

- **`polygon` as a list of rings:** `[boundary, island, island, ...]`. The first ring is the boundary and every later ring is an island inside it. This is the GeoJSON Polygon ring order, with `[lat, lng]` points like everywhere else in the API.
- **An area's `polygon` as a list of rings:** the same form inside `areas[]`, for islands inside one named area.
- **`exclude`:** a top-level list of rings, `[[[lat, lng], ...], ...]`, taken out of the `polygon`, or out of every area it overlaps. Use it when one island spans several named areas, or when you keep islands separately from boundaries. It also works with `/detect/address`, where the parcel is the boundary.

You can combine them. Rings don't need to repeat their first point.

A boundary with two islands:

```bash
curl https://trutec.ai/api/v1/detect/full \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "polygon": [
      [[30.40112, -97.72231], [30.40109, -97.72058], [30.39985, -97.72061], [30.39988, -97.72236]],
      [[30.40060, -97.72160], [30.40060, -97.72130], [30.40040, -97.72130], [30.40040, -97.72160]],
      [[30.40025, -97.72100], [30.40025, -97.72080], [30.40010, -97.72080], [30.40010, -97.72100]]
    ],
    "unit": "sf"
  }'
```

The response confirms what was left out:

```json
"excluded_islands": {"count": 2, "area_sqft": 10354.3}
```

Named areas with one island inside the north lot and one island that spans both lots:

```json
{
  "areas": [
    {"name": "North lot", "polygon": [
      [[30.40112, -97.72231], [30.40109, -97.72058], [30.40048, -97.72059], [30.40050, -97.72233]],
      [[30.40095, -97.72200], [30.40095, -97.72180], [30.40080, -97.72180], [30.40080, -97.72200]]
    ]},
    {"name": "South lot", "polygon": [[30.40050, -97.72233], [30.40048, -97.72059], [30.39985, -97.72061], [30.39988, -97.72236]]}
  ],
  "exclude": [
    [[30.40056, -97.72150], [30.40056, -97.72130], [30.40042, -97.72130], [30.40042, -97.72150]]
  ],
  "unit": "sf"
}
```

The matching part of the response (`counter_summary` trimmed):

```json
"excluded_islands": {"count": 2, "area_sqft": 6672.8},
"areas": [
  {"name": "North lot", "area_sqft": 46371, "excluded_islands": {"count": 2, "area_sqft": 5078.0}, "counter_summary": {}},
  {"name": "South lot", "area_sqft": 49112, "excluded_islands": {"count": 1, "area_sqft": 1594.8}, "counter_summary": {}}
]
```

Ground inside an island is left out by the same rules as ground outside the boundary:

- **Markings are counted whole or not at all.** A stall, handicap stall, truck stall, arrow, symbol, crosswalk, hatched area or stop bar is kept only if its center is outside every island and at least 40% of it lies outside the islands (90% for arrows and stop bars). A marking is never split between an island and the lot.
- **Stripes are cut at island edges.** Only the length outside the islands counts, and each remaining piece is its own line in `lines[]`.
- **Paved area leaves the islands out.** An island inside the pavement becomes a hole in `parking_areas` (an island across its edge cuts a notch), and `summary.total_area`, `parking_areas[].area_sqft` and each area's `area_sqft` exclude it.
- **Stall linear feet** are measured from the stalls that are kept.
- **Concrete and sidewalk are cut at island edges** like pavement, and each piece's `area_sqft` is the part outside the islands.
- Carport shapes are kept or left out whole, by a point inside each.
- With named areas, nothing inside an island goes to the area around it, even within 1.5 m of the island's edge. If another area you sent also covers that ground, the item goes to that area.

An island partly outside its boundary takes out only the part that overlaps. Detection runs on the whole boundary, so islands never change what is measured outside them.

`excluded_islands` is in the response only when the request has islands:

- `count`: islands applied. Per area, the islands that overlap that area (its own and any from `exclude`).
- `area_sqft`: ground the islands take out of your boundary, in square feet. This is the islands' area inside the boundary, not the detected pavement, which can be less. Per area, the part of that area's polygon they take out.

In GeoJSON output the same object is in the collection's `properties`, and per area in `properties.areas[]`.

Rules, checked before the request counts as a detection:

- Each island is a ring of at least 3 distinct `[lat, lng]` points, with at most 2,000 points.
- Up to 50 islands per request, counting `polygon`, `areas` and `exclude` together.
- An island ring in `polygon` or an area's `polygon` must overlap that boundary, and an `exclude` ring must overlap the `polygon` or at least one area. This catches swapped coordinates, and separate polygons sent as rings (send those as `areas`).
- `exclude` needs a `polygon` or `areas` to take the islands out of.
- Islands can't cover the whole boundary or a whole area.

| Status | `error` |
|---|---|
| 400 | `polygon[1] does not overlap polygon[0]. The first ring is the boundary and every later ring is an island inside it; send separate polygons as separate areas.` |
| 400 | `exclude[0] does not overlap the polygon. Coordinates are [lat, lng], latitude first.` (`any area` with `areas`) |
| 400 | `polygon has nothing left to measure once the excluded islands are taken out.` (`areas[0].polygon has ...` with `areas`) |
| 400 | `exclude must be a list of rings; send a single island as [[[lat, lng], ...]].` |
| 400 | `exclude needs a polygon or areas: the islands are taken out of that boundary.` |
| 400 | `A request can exclude at most 50 islands.` |
| 400 | `polygon[2] must be a ring of at least 3 [lat, lng] points.` (also `exclude[0]`, `areas[0].polygon[1]`) |

## Add-on models

Four optional models measure more than pavement and striping. Turn them on with `true` on `/detect/full` or `/detect/address`. They are the same models the TruTec takeoff editor runs for these items.

| Flag | Adds | What you get |
|---|---|---|
| `include_curbs` | `curbs` | `count` (curb lines), `linear_feet`, and `lines[]` (`id`, `points`, `length_ft`) |
| `include_wheel_stops` | `wheel_stops` | `count` and `points[]` (`id`, `lat`, `lng`) |
| `include_site_objects` | `site_objects` | `count`, `count_by_class` (`parallel_stall`, `speed_bump`, `bollard`, each with `label` and `count`), and `points[]` (`id`, `class`, `label`, `lat`, `lng`, `confidence`) |
| `include_landscape` | `landscape` | `area_by_class` (for example `lawn`, `mulch`, `gravel`, `hedge`, `pond`: `label`, `count`, `area` in your `unit`, `area_sqft`), `total_area`, `count_by_class` (`deciduous`, `evergreen`, `palm`, `shrub`, `stump`, `fountain`), `tree_count`, `polygons[]` and `points[]` |

```json
{
  "curbs": {
    "status": "complete",
    "count": 14,
    "linear_feet": 1862.4,
    "follows_paved_edges": true,
    "lines": [{"id": "curbs-0", "points": [[30.40104, -97.72225], [30.40101, -97.72066]], "length_ft": 503.2}]
  },
  "site_objects": {
    "status": "complete",
    "count": 9,
    "count_by_class": {
      "parallel_stall": {"label": "Parallel stalls", "count": 6},
      "speed_bump": {"label": "Speed bumps", "count": 3},
      "bollard": {"label": "Bollards", "count": 0}
    },
    "points": [{"id": "site_objects-0", "class": "speed_bump", "label": "Speed bumps", "lat": 30.40061, "lng": -97.72190, "confidence": 0.82}]
  }
}
```

How they work:

- **Billing.** Add-ons run inside the same detection, so a request counts once however many you turn on.
- **Status.** Each section has `status`: `complete`, or `failed` with an `error` when that model couldn't run. `site_objects` can also be `partial` when one object type couldn't be detected; `error` names it and the other types are included. A failed add-on never fails the detection or the other add-ons, so check `status` before you use a section's numbers.
- **Where they look.** With a `polygon`, `areas` or parcels, only items inside them are kept. With only `bounds`, wheel stops and site objects are kept on the detected pavement (the whole box when pavement isn't measured), and landscaping covers the whole box. Curb lines follow the edges of the detected pavement, so keep `include_parking` on with `include_curbs`. If no pavement is measured, `follows_paved_edges` is `false` and curbs come back as short straight runs. For `areas` requests, each item gets `area` set to the named area it falls in. The add-on totals are for the whole request.
- **Time.** Each add-on adds time to the detection. Send `"async": true` when you turn them on.
- **Formats.** JSON and GeoJSON. In GeoJSON, add-on items are features with `properties.featureType` `curb`, `wheel_stop`, `site_object`, `landscape_area` or `landscape_point`, and the collection's `properties` holds each section without its geometry. KMZ requests with an add-on return `400`.
- **Landscape access.** `include_landscape` runs only when AI auto-measure is on in the workspace for the person who created the API key, the same rule the app applies. Otherwise the request returns `403` with `Landscape detection is turned off for this key` before anything is counted.
- **Saving.** When you [save the detection onto a job](takeoffs.md#save-a-detection-onto-a-job), curbs, wheel stops, speed bumps, bollards and parallel stalls become the takeoff's curb, wheel stop, speed bump, bollard and parallel stall items, so they show in the takeoff editor. Landscape results are not saved into the takeoff. See [Saving & managing takeoffs](takeoffs.md#save-a-detection-onto-a-job) for details.

## Measure by parcel

Use parcels when a site spans several lots, or when an address could match the wrong parcel.

1. Call [`GET /v1/parcels`](#find-parcels) with the site's `address` (or `lat` and `lng`).
2. Pick the parcels to measure. `owner`, `address`, `acres` and `distance_m` tell neighboring parcels apart.
3. Call `POST /v1/detect/full` with their ids in `parcel_ids` and the same `address` (or `lat` and `lng`).

```json
{
  "address": "1200 Commerce Dr, Austin, TX 78758",
  "parcel_ids": ["2f6a1c7e-90b4-4d1e-8f3a-5b2c7d9e0a14", "8c3d5e1f-2a7b-4c90-b6d4-1e9f0a3b7c25"],
  "unit": "sf",
  "async": true
}
```

TruTec looks the parcels up again around that location, so send the same `address` or point you searched with. Then:

- **Parcels that touch** (or one parcel in one piece) are measured as one outline, exactly like sending their combined boundary as `polygon`.
- **Parcels that don't touch**, or that surround a parcel you didn't pick, are measured as [named areas](#named-areas), one per parcel, in the order you sent them. Each area is named after the parcel's address, or `APN <number>` when it has none; a repeated name gets ` (2)`, ` (3)` and so on. A parcel in several pieces gets one area per piece. The response adds `areas[]` as usual.

It is one detection, however many parcels you send. Up to 10 parcels per request. A parcel id that isn't found near the location returns `400` before anything is counted; search again with the same location to get current ids.

`POST /v1/detect/address` also takes `parcel_ids`, using its `address` as the location.

## Imagery

By default, TruTec measures on **Google** satellite imagery, and the response says `"imagery_source": {"type": "google"}`. Google does not publish capture dates for its imagery, so the API cannot return one for these measurements.

To measure on **Nearmap** imagery, pass the survey you want in `nearmap_survey_id`. Nearmap is available on Silver plans and above. The response then includes:

```json
"imagery_source": {"type": "nearmap", "survey_id": "88f1e2a0-1234-5678-abcd-1234567890ab", "date": "2026-03-14"}
```

`date` is the `nearmap_date` you sent, copied back. TruTec does not look up or check it, and it is `null` when you don't send one. The API does not list the Nearmap surveys available at a location. Get the survey id and its capture date from your own Nearmap account, or from a takeoff made in the TruTec app.

For `/detect/tiles`, `imagery_source` describes your uploaded coverage instead.

Imagery can be months or years old. If the site has changed (new striping, new pavement), measure on a newer survey or upload your own imagery.

For imagery behind saved takeoffs, see [Saved measurements & imagery](measurements.md#imagery-source-and-capture-date).

## Limits and validation

| Limit | Value |
|---|---|
| Largest detection | 1,000 acres, measured on the box around your `bounds`, `polygon` or `areas` |
| `polygon` | One ring, at least 3 points, `[lat, lng]` numbers only, or `[boundary, island, ...]` rings. No multi-polygons. A ring wrapped once (`[[[lat, lng], ...]]`) is accepted. |
| `areas` | 1 to 20 areas, unique names up to 64 characters, up to 2,000 points each |
| Excluded islands | 50 per request (`polygon`, `areas` and `exclude` together), up to 2,000 points each |
| `parcel_ids` | 1 to 10 parcels, found within 500 m of the `address` or `lat`/`lng` sent with them |
| Synchronous request time | About 100 seconds. Use `async` for larger sites. |
| Uploaded tiles | 32 images, 8 MiB decoded, 8,388,608 pixels, 12 MiB request body |
| Small shapes filtered out | Pavement pieces under 200 sq ft, holes under 50 sq ft, hatching under 5 sq ft |

## Usage and billing

Each detection request counts as one detection on the API key that sent it: `/detect/full`, `/detect/address` and `/detect/tiles` alike, whatever the size of the site. Named areas and parcels in one request count once. Polling `/detect/status`, reading results and finding parcels with `GET /v1/parcels` do not count.

A request is counted once it passes the request-body, `polygon`, `areas`, `exclude` and `parcel_ids` checks (and, for `/detect/address`, once the address and parcel are found). These are **not** counted:

- authentication, plan and permission errors;
- a missing body, a malformed `polygon`, `areas` or `exclude` (islands included), or a non-boolean `marking_details` or add-on flag;
- an add-on with `kmz` output, or `include_landscape` refused for the key's creator;
- an address or parcel that can't be found, including a `parcel_ids` id;
- an invalid `/detect/tiles` upload.

Everything after that point counts, including a request rejected for missing `bounds`, an area over 1,000 acres, or no detection type, and a detection that fails while running. Check those before you send, and don't retry a failed detection in a tight loop.

If TruTec has set a usage limit on your key, requests past it return `429` with `API usage limit exceeded` and a `usage` object (`count`, `limit`). Pricing for API detections is set by your plan or agreement.

### List plans

`GET /v1/plans`

**Permission:** Any valid key

Lists TruTec's standard subscription plans and prices, as shown in the app. `credits` is the number of takeoff reports a plan includes each month.

```bash
curl https://trutec.ai/api/v1/plans -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "plans": [
    {
      "plan": "silver",
      "display_name": "Silver (Per Report)",
      "price_cents": 3000,
      "price_display": "$30/report",
      "billing": "per_report",
      "credits": null,
      "free_reports": 2,
      "features": ["Instant Takeoffs", "Custom Bid Reports", "Nearmap Imagery"],
      "overage_cost_cents": null
    },
    {
      "plan": "gold",
      "display_name": "Gold",
      "price_cents": 50000,
      "price_display": "$500/month",
      "billing": "monthly",
      "credits": 30,
      "features": ["Instant Takeoffs", "Custom Bid Reports", "API Access", "Nearmap Imagery"],
      "overage_cost_cents": 3000
    }
  ]
}
```

The full list also includes `platinum` and `diamond`.

### Check the service

`GET /v1/health`

**Permission:** None (no key needed)

Returns `200` when the API is up. Use it for uptime checks.

```bash
curl https://trutec.ai/api/v1/health
```

```json
{"status": "ok", "version": "v1"}
```
