Skip to content
TruTecDevelopers
Get an API key

Measure a site

Send an address, a box, an outline, named areas or property parcels and get back paved area, stalls, striping and markings measured from aerial imagery.

View as Markdown

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.

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

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.
areas array One of bounds, polygon, areas, parcel_ids 1 to 20 objects, each {"name": "North lot", "polygon": [[lat, lng], ...]}. See 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.
parcel_ids array of strings One of bounds, polygon, areas, parcel_ids 1 to 10 parcel ids from GET /v1/parcels. Send with address, or lat and lng, and not with bounds, polygon or areas. See 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.
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.
nearmap_survey_id string No Measure on a specific Nearmap survey instead of the default imagery. See 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.
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.

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:

{
  "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 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.
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)
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 (searched with the same address) instead of one picked by parcel_index. Don't send with parcel_index. See 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).

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}'
{
  "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 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). 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.
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"
{
  "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.

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.

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

While running:

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

When done, result holds the full detection response:

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

{"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#

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

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.

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.

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

{
  "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).
  • 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:

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:

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

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

{
  "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):

"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[]
{
  "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, 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 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 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).
{
  "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, 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:

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

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.

curl https://trutec.ai/api/v1/plans -H "X-API-Key: sk_your_api_key_here"
{
  "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.

curl https://trutec.ai/api/v1/health
{"status": "ok", "version": "v1"}