Estimates
Quotes. This is the only write that creates real money-shaped work, so it is the one to send an Idempotency-Key with.
https://app.treeinventory.ai/api/v1/estimatesCreate an estimate
Post the whole quote at once. Totals are computed here from the quantities and unit prices you send; do not send a total, it would be ignored. Send an Idempotency-Key: a timed-out retry without one creates a second quote, and the arborist finds out when a customer asks which is real.
Scope: estimates:write
Headers
| Field | Type | Notes |
|---|---|---|
| Idempotency-Key | uuid | A UUID you mint per logical action and REUSE on every retry of it. Replay returns the original response and creates nothing. Omit it and a retry creates a second record. |
Request body
| Field | Type | Notes |
|---|---|---|
| siteIdrequired | uuid | |
| linesrequired | object[] |
Request body → lines
| Field | Type | Notes |
|---|---|---|
| catalogItemIdrequired | uuid | From GET /catalog/items. Inventing one is a 404. If nothing in the catalog matches the work you are quoting, pick the NEAREST item and override description and unitPriceCents. That is the intended pattern, not a workaround: the catalog carries the org's default prices, and your line carries what you are actually charging. |
| description | string | What the customer reads on this line. Send the real work here when the catalog item you referenced is only the nearest match. |
| quantityrequired | integer | A whole number, in the catalog item's own unit. Most are per tree; a few name their unit in the item name, such as Lawn Mowing (per 100 sq ft), and there you send the number of units, so 250 sq ft of mowing is a quantity of 3. Fractions are not stored: the column is an integer, and sending 2.5 used to fail as a 500 rather than tell you this. |
| unitPriceCentsrequired | integer | Whole US cents. 45000 is $450.00. There is no currency field. |
Example request
{
"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
}
]
}data
| Field | Type | Notes |
|---|---|---|
| id | uuid | |
| number | string | "JOB-0007", which is what the customer sees. |
| status | string | |
| siteId | uuid | |
| customerId | uuid or null | Who pays. Always set on an estimate created through this API: it is taken from the site, because you already named the customer when you created the site. It can be null on an estimate created in the app, where attaching a customer is a separate step. An invoice cannot be issued without it. |
| origin | string | |
| totalCents | integer | |
| unpricedCount | integer | Non-zero means this estimate cannot be sent as-is. |
| narrative | string or null | |
| flags | string[] | |
| expiresAt | string or null | |
| sentAt | string or null | |
| acceptedAt | string or null | |
| lines | EstimateLine[] | |
| createdAt | string | |
| updatedAt | string |
data → lines
| Field | Type | Notes |
|---|---|---|
| id | uuid | |
| lineNumber | integer | |
| description | string or null | |
| coverage | string or null | What this line covers, as it appears on the customer document. |
| quantity | number | |
| unitPriceCents | integer or null | |
| amountCents | integer or null | Null means UNPRICED (no rate on file). It is not zero, and treating it as zero publishes free work. |
| status | string | pending until a crew marks the work done in the app. Nothing in this API changes it. |
| catalogItemId | uuid or null | |
| siteId | uuid or null | Null on a single-site estimate, which is the normal case: the line belongs to the estimate's own site. Only set on an estimate that covers more than one site. |
| treeIds | uuid[] | Which captured trees this line prices. Empty when the line was quoted by count rather than from inventory, which is what every line created through this API is. |
Example response 201
{
"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,
"narrative": null,
"flags": [],
"expiresAt": "2026-09-25T00:00:00.000Z",
"sentAt": null,
"acceptedAt": null,
"lines": [
{
"id": "11111111-2222-4333-8444-555555555555",
"lineNumber": 1,
"description": "Crown clean, front oaks",
"coverage": null,
"quantity": 2,
"unitPriceCents": 45000,
"amountCents": 90000,
"status": "pending",
"catalogItemId": "7b1d0c94-2f63-4e8a-9a55-c3d8e0f1a2b3",
"siteId": null,
"treeIds": []
},
{
"id": "66666666-7777-4888-8999-aaaaaaaaaaaa",
"lineNumber": 2,
"description": "Deadwood, rear maple",
"coverage": null,
"quantity": 1,
"unitPriceCents": 32500,
"amountCents": 32500,
"status": "pending",
"catalogItemId": "7b1d0c94-2f63-4e8a-9a55-c3d8e0f1a2b3",
"siteId": null,
"treeIds": []
}
],
"createdAt": "2026-08-25T18:22:41.771Z",
"updatedAt": "2026-08-25T18:22:41.771Z"
}
}Failures
401Missing, malformed, revoked or expired credential. TheWWW-Authenticateheader names the scheme, which isApiKey. Carries noRateLimit-*headers: the credential was never resolved, so there is no bucket to report.403The key is valid but does not carry the scope this operation requires. The message names the scope, so you can ask for exactly the grant you need. Carries noRateLimit-*headers: a request refused for scope is not counted against your limit.404No such record in your organization. A record belonging to somebody else is indistinguishable from one that does not exist.409Idempotency conflict.idempotency_key_reusedmeans thisIdempotency-Keywas already used with a DIFFERENT request body: mint a new key for a new request, or resend the original body to replay.command_in_progressmeans an identical request is still in flight; waitRetry-Afterseconds and retry.422Validation failed. Every bad field is named, at once.429Rate limit exceeded.500Something failed on our side. Retry with the SAME Idempotency-Key; a 5xx is the one status where retrying is correct.
https://app.treeinventory.ai/api/v1/estimates/{estimateId}Get one estimate
The read that makes a write checkable.
Scope: estimates:read
Path parameters
| Field | Type | Notes |
|---|---|---|
| estimateIdrequired | uuid | The estimate's id. |
data
| Field | Type | Notes |
|---|---|---|
| id | uuid | |
| number | string | "JOB-0007", which is what the customer sees. |
| status | string | |
| siteId | uuid | |
| customerId | uuid or null | Who pays. Always set on an estimate created through this API: it is taken from the site, because you already named the customer when you created the site. It can be null on an estimate created in the app, where attaching a customer is a separate step. An invoice cannot be issued without it. |
| origin | string | |
| totalCents | integer | |
| unpricedCount | integer | Non-zero means this estimate cannot be sent as-is. |
| narrative | string or null | |
| flags | string[] | |
| expiresAt | string or null | |
| sentAt | string or null | |
| acceptedAt | string or null | |
| lines | EstimateLine[] | |
| createdAt | string | |
| updatedAt | string |
data → lines
| Field | Type | Notes |
|---|---|---|
| id | uuid | |
| lineNumber | integer | |
| description | string or null | |
| coverage | string or null | What this line covers, as it appears on the customer document. |
| quantity | number | |
| unitPriceCents | integer or null | |
| amountCents | integer or null | Null means UNPRICED (no rate on file). It is not zero, and treating it as zero publishes free work. |
| status | string | pending until a crew marks the work done in the app. Nothing in this API changes it. |
| catalogItemId | uuid or null | |
| siteId | uuid or null | Null on a single-site estimate, which is the normal case: the line belongs to the estimate's own site. Only set on an estimate that covers more than one site. |
| treeIds | uuid[] | Which captured trees this line prices. Empty when the line was quoted by count rather than from inventory, which is what every line created through this API is. |
Example response 200
{
"success": true,
"message": "Estimate retrieved",
"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,
"narrative": null,
"flags": [],
"expiresAt": "2026-09-25T00:00:00.000Z",
"sentAt": null,
"acceptedAt": null,
"lines": [
{
"id": "11111111-2222-4333-8444-555555555555",
"lineNumber": 1,
"description": "Crown clean, front oaks",
"coverage": null,
"quantity": 2,
"unitPriceCents": 45000,
"amountCents": 90000,
"status": "pending",
"catalogItemId": "7b1d0c94-2f63-4e8a-9a55-c3d8e0f1a2b3",
"siteId": null,
"treeIds": []
},
{
"id": "66666666-7777-4888-8999-aaaaaaaaaaaa",
"lineNumber": 2,
"description": "Deadwood, rear maple",
"coverage": null,
"quantity": 1,
"unitPriceCents": 32500,
"amountCents": 32500,
"status": "pending",
"catalogItemId": "7b1d0c94-2f63-4e8a-9a55-c3d8e0f1a2b3",
"siteId": null,
"treeIds": []
}
],
"createdAt": "2026-08-25T18:22:41.771Z",
"updatedAt": "2026-08-25T18:22:41.771Z"
}
}Failures
401Missing, malformed, revoked or expired credential. TheWWW-Authenticateheader names the scheme, which isApiKey. Carries noRateLimit-*headers: the credential was never resolved, so there is no bucket to report.403The key is valid but does not carry the scope this operation requires. The message names the scope, so you can ask for exactly the grant you need. Carries noRateLimit-*headers: a request refused for scope is not counted against your limit.404No such record in your organization. A record belonging to somebody else is indistinguishable from one that does not exist.422Validation failed. Every bad field is named, at once.429Rate limit exceeded.500Something failed on our side. Retry with the SAME Idempotency-Key; a 5xx is the one status where retrying is correct.
https://app.treeinventory.ai/api/v1/estimates/{estimateId}/acceptAccept an estimate
Record that the customer said yes. This is the step that turns a quote into work: an accepted estimate is what scheduling and routing look at, and a draft is invisible to both. Nothing is emailed to the customer from here. A draft or a sent estimate can be accepted; accepting one that is already accepted replays and answers 200, so a retry after a timeout is safe without an Idempotency-Key. A declined or canceled estimate answers 422.
Scope: estimates:write
Path parameters
| Field | Type | Notes |
|---|---|---|
| estimateIdrequired | uuid | The estimate's id. |
Headers
| Field | Type | Notes |
|---|---|---|
| Idempotency-Key | uuid | A UUID you mint per logical action and REUSE on every retry of it. Replay returns the original response and creates nothing. Omit it and a retry creates a second record. |
data
| Field | Type | Notes |
|---|---|---|
| id | uuid | |
| number | string | "JOB-0007", which is what the customer sees. |
| status | string | |
| siteId | uuid | |
| customerId | uuid or null | Who pays. Always set on an estimate created through this API: it is taken from the site, because you already named the customer when you created the site. It can be null on an estimate created in the app, where attaching a customer is a separate step. An invoice cannot be issued without it. |
| origin | string | |
| totalCents | integer | |
| unpricedCount | integer | Non-zero means this estimate cannot be sent as-is. |
| narrative | string or null | |
| flags | string[] | |
| expiresAt | string or null | |
| sentAt | string or null | |
| acceptedAt | string or null | |
| lines | EstimateLine[] | |
| createdAt | string | |
| updatedAt | string |
data → lines
| Field | Type | Notes |
|---|---|---|
| id | uuid | |
| lineNumber | integer | |
| description | string or null | |
| coverage | string or null | What this line covers, as it appears on the customer document. |
| quantity | number | |
| unitPriceCents | integer or null | |
| amountCents | integer or null | Null means UNPRICED (no rate on file). It is not zero, and treating it as zero publishes free work. |
| status | string | pending until a crew marks the work done in the app. Nothing in this API changes it. |
| catalogItemId | uuid or null | |
| siteId | uuid or null | Null on a single-site estimate, which is the normal case: the line belongs to the estimate's own site. Only set on an estimate that covers more than one site. |
| treeIds | uuid[] | Which captured trees this line prices. Empty when the line was quoted by count rather than from inventory, which is what every line created through this API is. |
Example response 200
{
"success": true,
"message": "Estimate accepted",
"data": {
"id": "e5d4c3b2-a190-4877-b6e5-d4c3b2a19087",
"number": "JOB-0062",
"status": "accepted",
"siteId": "3a7c9e21-5b48-4f0a-9d33-71c6ee204b18",
"customerId": "9f1b2c3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d",
"origin": "manual",
"totalCents": 122500,
"unpricedCount": 0,
"narrative": null,
"flags": [],
"expiresAt": "2026-09-25T00:00:00.000Z",
"sentAt": null,
"acceptedAt": "2026-03-04T16:12:09.482Z",
"lines": [
{
"id": "11111111-2222-4333-8444-555555555555",
"lineNumber": 1,
"description": "Crown clean, front oaks",
"coverage": null,
"quantity": 2,
"unitPriceCents": 45000,
"amountCents": 90000,
"status": "pending",
"catalogItemId": "7b1d0c94-2f63-4e8a-9a55-c3d8e0f1a2b3",
"siteId": null,
"treeIds": []
},
{
"id": "66666666-7777-4888-8999-aaaaaaaaaaaa",
"lineNumber": 2,
"description": "Deadwood, rear maple",
"coverage": null,
"quantity": 1,
"unitPriceCents": 32500,
"amountCents": 32500,
"status": "pending",
"catalogItemId": "7b1d0c94-2f63-4e8a-9a55-c3d8e0f1a2b3",
"siteId": null,
"treeIds": []
}
],
"createdAt": "2026-08-25T18:22:41.771Z",
"updatedAt": "2026-08-25T18:22:41.771Z"
}
}Failures
401Missing, malformed, revoked or expired credential. TheWWW-Authenticateheader names the scheme, which isApiKey. Carries noRateLimit-*headers: the credential was never resolved, so there is no bucket to report.403The key is valid but does not carry the scope this operation requires. The message names the scope, so you can ask for exactly the grant you need. Carries noRateLimit-*headers: a request refused for scope is not counted against your limit.404No such record in your organization. A record belonging to somebody else is indistinguishable from one that does not exist.409Idempotency conflict.idempotency_key_reusedmeans thisIdempotency-Keywas already used with a DIFFERENT request body: mint a new key for a new request, or resend the original body to replay.command_in_progressmeans an identical request is still in flight; waitRetry-Afterseconds and retry.422Validation failed. Every bad field is named, at once.429Rate limit exceeded.500Something failed on our side. Retry with the SAME Idempotency-Key; a 5xx is the one status where retrying is correct.