Tree Inventory AI

Estimates

Quotes. This is the only write that creates real money-shaped work, so it is the one to send an Idempotency-Key with.

posthttps://app.treeinventory.ai/api/v1/estimates

Create 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

FieldTypeNotes
Idempotency-KeyuuidA 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

FieldTypeNotes
siteIdrequireduuid
linesrequiredobject[]

Request body → lines

FieldTypeNotes
catalogItemIdrequireduuidFrom 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.
descriptionstringWhat the customer reads on this line. Send the real work here when the catalog item you referenced is only the nearest match.
quantityrequiredintegerA 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.
unitPriceCentsrequiredintegerWhole 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

FieldTypeNotes
iduuid
numberstring"JOB-0007", which is what the customer sees.
statusstring
siteIduuid
customerIduuid or nullWho 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.
originstring
totalCentsinteger
unpricedCountintegerNon-zero means this estimate cannot be sent as-is.
narrativestring or null
flagsstring[]
expiresAtstring or null
sentAtstring or null
acceptedAtstring or null
linesEstimateLine[]
createdAtstring
updatedAtstring

data → lines

FieldTypeNotes
iduuid
lineNumberinteger
descriptionstring or null
coveragestring or nullWhat this line covers, as it appears on the customer document.
quantitynumber
unitPriceCentsinteger or null
amountCentsinteger or nullNull means UNPRICED (no rate on file). It is not zero, and treating it as zero publishes free work.
statusstringpending until a crew marks the work done in the app. Nothing in this API changes it.
catalogItemIduuid or null
siteIduuid or nullNull 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.
treeIdsuuid[]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

  • 401 Missing, malformed, revoked or expired credential. The WWW-Authenticate header names the scheme, which is ApiKey. Carries no RateLimit-* headers: the credential was never resolved, so there is no bucket to report.
  • 403 The 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 no RateLimit-* headers: a request refused for scope is not counted against your limit.
  • 404 No such record in your organization. A record belonging to somebody else is indistinguishable from one that does not exist.
  • 409 Idempotency conflict. idempotency_key_reused means this Idempotency-Key was already used with a DIFFERENT request body: mint a new key for a new request, or resend the original body to replay. command_in_progress means an identical request is still in flight; wait Retry-After seconds and retry.
  • 422 Validation failed. Every bad field is named, at once.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. Retry with the SAME Idempotency-Key; a 5xx is the one status where retrying is correct.
gethttps://app.treeinventory.ai/api/v1/estimates/{estimateId}

Get one estimate

The read that makes a write checkable.

Scope: estimates:read

Path parameters

FieldTypeNotes
estimateIdrequireduuidThe estimate's id.

data

FieldTypeNotes
iduuid
numberstring"JOB-0007", which is what the customer sees.
statusstring
siteIduuid
customerIduuid or nullWho 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.
originstring
totalCentsinteger
unpricedCountintegerNon-zero means this estimate cannot be sent as-is.
narrativestring or null
flagsstring[]
expiresAtstring or null
sentAtstring or null
acceptedAtstring or null
linesEstimateLine[]
createdAtstring
updatedAtstring

data → lines

FieldTypeNotes
iduuid
lineNumberinteger
descriptionstring or null
coveragestring or nullWhat this line covers, as it appears on the customer document.
quantitynumber
unitPriceCentsinteger or null
amountCentsinteger or nullNull means UNPRICED (no rate on file). It is not zero, and treating it as zero publishes free work.
statusstringpending until a crew marks the work done in the app. Nothing in this API changes it.
catalogItemIduuid or null
siteIduuid or nullNull 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.
treeIdsuuid[]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

  • 401 Missing, malformed, revoked or expired credential. The WWW-Authenticate header names the scheme, which is ApiKey. Carries no RateLimit-* headers: the credential was never resolved, so there is no bucket to report.
  • 403 The 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 no RateLimit-* headers: a request refused for scope is not counted against your limit.
  • 404 No such record in your organization. A record belonging to somebody else is indistinguishable from one that does not exist.
  • 422 Validation failed. Every bad field is named, at once.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. Retry with the SAME Idempotency-Key; a 5xx is the one status where retrying is correct.
posthttps://app.treeinventory.ai/api/v1/estimates/{estimateId}/accept

Accept 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

FieldTypeNotes
estimateIdrequireduuidThe estimate's id.

Headers

FieldTypeNotes
Idempotency-KeyuuidA 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

FieldTypeNotes
iduuid
numberstring"JOB-0007", which is what the customer sees.
statusstring
siteIduuid
customerIduuid or nullWho 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.
originstring
totalCentsinteger
unpricedCountintegerNon-zero means this estimate cannot be sent as-is.
narrativestring or null
flagsstring[]
expiresAtstring or null
sentAtstring or null
acceptedAtstring or null
linesEstimateLine[]
createdAtstring
updatedAtstring

data → lines

FieldTypeNotes
iduuid
lineNumberinteger
descriptionstring or null
coveragestring or nullWhat this line covers, as it appears on the customer document.
quantitynumber
unitPriceCentsinteger or null
amountCentsinteger or nullNull means UNPRICED (no rate on file). It is not zero, and treating it as zero publishes free work.
statusstringpending until a crew marks the work done in the app. Nothing in this API changes it.
catalogItemIduuid or null
siteIduuid or nullNull 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.
treeIdsuuid[]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

  • 401 Missing, malformed, revoked or expired credential. The WWW-Authenticate header names the scheme, which is ApiKey. Carries no RateLimit-* headers: the credential was never resolved, so there is no bucket to report.
  • 403 The 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 no RateLimit-* headers: a request refused for scope is not counted against your limit.
  • 404 No such record in your organization. A record belonging to somebody else is indistinguishable from one that does not exist.
  • 409 Idempotency conflict. idempotency_key_reused means this Idempotency-Key was already used with a DIFFERENT request body: mint a new key for a new request, or resend the original body to replay. command_in_progress means an identical request is still in flight; wait Retry-After seconds and retry.
  • 422 Validation failed. Every bad field is named, at once.
  • 429 Rate limit exceeded.
  • 500 Something failed on our side. Retry with the SAME Idempotency-Key; a 5xx is the one status where retrying is correct.