Tree Inventory AI

From a spreadsheet to a quote

You have a row from a customer's spreadsheet: *Delap Road Properties, 3760 W Delap Rd, crown clean two front oaks at $450 each, deadwood the rear maple at $325.* Four calls turn that into a draft estimate the arborist can review and send.

Mint one UUID before you start and use it as the `Idempotency-Key` on every write below. If the connection drops halfway, run the whole thing again with the same ids: nothing will be created twice.

1. Find the customer

You have a name, not an id. Search for it before creating anything, or you will create a duplicate of a customer who already exists.

curl -s "https://treeinventory.ai/api/v1/customers?search=Delap" \
  -H "Authorization: ApiKey tiai_YOUR_KEY"

Response

{
  "success": true,
  "message": "Customers retrieved",
  "data": {
    "items": [
      {
        "id": "9f1b2c3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d",
        "name": "Delap Road Properties",
        "email": "office@delaproad.example",
        "phone": "(812) 555-0134",
        "createdAt": "2026-08-19T14:02:11.412Z",
        "updatedAt": "2026-08-19T14:02:11.412Z"
      }
    ],
    "nextCursor": null
  }
}

No match? `POST /customers` with the name, then carry on. That call takes an `Idempotency-Key` too.

2. Find the site

An estimate prices work at a place. Filter the customer's sites and match the street line you were given.

curl -s "https://treeinventory.ai/api/v1/sites?customerId=9f1b2c3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d" \
  -H "Authorization: ApiKey tiai_YOUR_KEY"

Response

{
  "success": true,
  "message": "Sites retrieved",
  "data": {
    "items": [
      {
        "id": "3a7c9e21-5b48-4f0a-9d33-71c6ee204b18",
        "customerId": "9f1b2c3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d",
        "label": "Front acre",
        "street": "3760 W Delap Rd",
        "city": "Bloomington",
        "state": "IN",
        "zip": "47404",
        "country": "US",
        "latitude": 39.1912,
        "longitude": -86.5847,
        "createdAt": "2026-08-19T14:05:47.008Z",
        "updatedAt": "2026-08-19T14:05:47.008Z"
      }
    ],
    "nextCursor": null
  }
}

No match? `POST /sites` with the street, city, state and zip. Leave latitude and longitude out and we geocode it.

3. Read the catalog

Every estimate line references a catalog item id. This is the step people skip, and it is why their first POST /estimates returns a 404 they cannot explain.

curl -s "https://treeinventory.ai/api/v1/catalog/items" \
  -H "Authorization: ApiKey tiai_YOUR_KEY"

Response

{
  "success": true,
  "message": "Catalog items retrieved",
  "data": {
    "items": [
      {
        "id": "7b1d0c94-2f63-4e8a-9a55-c3d8e0f1a2b3",
        "type": "service",
        "name": "Crown clean",
        "description": "Remove dead, dying, diseased and crossing branches.",
        "category": "pruning",
        "unitPriceCents": 45000,
        "active": true,
        "workTypeHint": "trim",
        "createdAt": "2026-06-02T09:12:00.000Z",
        "updatedAt": "2026-06-02T09:12:00.000Z"
      }
    ],
    "nextCursor": null
  }
}

The catalog price is the org's default. You are free to send a different `unitPriceCents` on the line: the spreadsheet you were handed is the source of truth for this quote.

4. Write the estimate

One call, the whole quote. Prices are in whole cents. Do not send a total. It is computed here from your quantities and unit prices, and a total you send would be ignored.

curl -s -X POST "https://treeinventory.ai/api/v1/estimates" \
  -H "Authorization: ApiKey tiai_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 4d0f2b16-9c3a-4e51-a8b7-2f6d1c0e3a94" \
  -d @quote.json

quote.json

{
  "siteId": "3a7c9e21-5b48-4f0a-9d33-71c6ee204b18",
  "lines": [
    {
      "catalogItemId": "7b1d0c94-2f63-4e8a-9a55-c3d8e0f1a2b3",
      "description": "Crown clean, front oaks",
      "quantity": 2,
      "unitPriceCents": 45000
    },
    {
      "catalogItemId": "7b1d0c94-2f63-4e8a-9a55-c3d8e0f1a2b3",
      "description": "Deadwood, rear maple",
      "quantity": 1,
      "unitPriceCents": 32500
    }
  ]
}

Response

{
  "success": true,
  "message": "Estimate created",
  "data": {
    "id": "e5d4c3b2-a190-4877-b6e5-d4c3b2a19087",
    "number": "JOB-0062",
    "status": "draft",
    "siteId": "3a7c9e21-5b48-4f0a-9d33-71c6ee204b18",
    "customerId": "9f1b2c3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d",
    "origin": "manual",
    "totalCents": 122500,
    "unpricedCount": 0,
    "lines": [
      { "lineNumber": 1, "description": "Crown clean, front oaks", "quantity": 2, "unitPriceCents": 45000, "amountCents": 90000 },
      { "lineNumber": 2, "description": "Deadwood, rear maple", "quantity": 1, "unitPriceCents": 32500, "amountCents": 32500 }
    ],
    "createdAt": "2026-08-25T18:22:41.771Z",
    "updatedAt": "2026-08-25T18:22:41.771Z"
  }
}

Send that same request again with the same `Idempotency-Key` and you get the same estimate back with an `Idempotent-Replay: true` header. Nothing is created. Change the body and keep the key, and you get a 409 instead of a silently wrong answer.

The estimate lands as a **draft**. It is not sent, and no customer sees it until the arborist reviews it in the app and sends it. That is deliberate: an API that could mail a price to somebody's customer without a human looking at it is not a feature anyone asked for.

Read it back with `GET /api/v1/estimates/{id}` whenever you want to check what landed.