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 a fresh UUID for each write below and send it as that write's
Idempotency-Key. One key per write, not one key for the run: a key is
bound to the request it first completed, so reusing it for a different
call answers 409.
Keep those keys. If the connection drops and you do not know whether a write landed, send it again with the same key: you get the original result back rather than a second customer, site or estimate.
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://app.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, and it needs its own, not the one you used here.
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://app.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, and there is no free-text line. This is the step people skip, and it is why their first POST /estimates returns a 404 they cannot explain.
curl -s "https://app.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",
"renewalIntervalMonths": null,
"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.
Nothing matching your work? That is normal and it has an answer. Pruning is catalogued by trunk diameter, not by operation, so a row that says "crown clean" will not find an item called that. Pick the nearest item, then put the real work in description and the real money in unitPriceCents. The example below does exactly that: both lines reference one pruning item and describe different work.
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://app.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.jsonquote.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.