Tree Inventory AI

Schedule

Placing accepted work on a crew and a day, and reading back what is placed. This is the step between a quote and a truck leaving the yard.

gethttps://app.treeinventory.ai/api/v1/schedule

List placed work

What is on the board, ordered by day and then by drive order within each crew's day. needsAttention is live work whose last placement is already in the past: it was never done and it is no longer on anybody's day, so it would otherwise vanish quietly. This is the read that makes a placement checkable.

Scope: schedule:read

Query parameters

FieldTypeNotes
fromstringPlacements on or after this day. Defaults to today, UTC.
tostringPlacements on or before this day, inclusive. Omit for everything from from onward. needsAttention ignores this: work that fell off the board is outstanding whatever window you asked about.
crewIdstring
qstringFree text over the site label and the job number. NOT the customer: unplaced work carries no customer name, and promising one here would be a search that silently never matches.
kindsarrayWhich kinds to include, comma-separated. Omit for all three. work_to_place is accepted work with no day, work_day is a placed crew-day, appointment is a visit.
categoriesarrayCatalogue categories, comma-separated, e.g. removal,plant_health. A POSITIVE filter: work with no catalogue-backed lines has no categories and does not match. Appointments sell nothing, so any value here excludes them.
crewIdsarrayCrew ids, comma-separated. Applies to placed crew-days only; it never drops appointments, which have no crew.
assigneeIdsarrayUser ids, comma-separated. Applies to appointments only.
minAgeDaysintegerAccepted at least this many WHOLE days ago. Work with no accepted date has no age and is excluded rather than counted as zero.
acceptedFromstringAccepted on or after this day.
acceptedTostringAccepted on or before this day, inclusive.
scheduledFromstringPlaced on or after this day. Unplaced work is excluded: it is not on the calendar, so it is not an answer to a question about the calendar.
scheduledTostringPlaced on or before this day, inclusive.
dueOnlystringOnly work whose next program round is due or overdue. A job that is not part of a program never matches.
estimateIdsarrayExactly these estimates, comma-separated. For a caller that already has the ids and wants their current state rather than a page of the queue.
districtIdsarrayDistrict ids, comma-separated. Matching ANY of them is enough. A site can be in more than one district, and work whose site is in none never matches.
bboxstringAn area, as west,south,east,north in degrees — the corners of a box on the map. Work with no coordinates is excluded, because a question about an area cannot be answered yes for something with no location. A box crossing the antimeridian (west > east) is supported.

data

FieldTypeNotes
upcomingScheduledWork[]
needsAttentionScheduledWork[]Live work whose LAST placement is in the past. It has not been done and it is no longer on anybody's day, so it would otherwise vanish quietly.

data → upcoming

FieldTypeNotes
scheduleIduuidThe placement, not the estimate.
estimateIduuid
estimateNumberstringFormatted, e.g. "JOB-0062".
estimateStatusstring
scheduledDatestringYYYY-MM-DD, the crew's local day.
serviceCategoriesstring[]Distinct catalogue categories across this estimate's lines. Also a query parameter on this endpoint.
districtIdsuuid[]Every named area this day's SITE falls in. Also a query parameter, and returned so a districtIds filter can be checked against the rows it gives back rather than taken on trust.
sortOrderintegerPosition within that crew's day, ascending. This is the drive order.
notestring or null
siteIduuid
siteLabelstring
latitudenumber or null
longitudenumber or null
totalCentsinteger
plannedHoursnumber or nullHow many of the estimate's hours are planned onto THIS day. A job spread over several days carries a share on each, and they need not be equal: a twelve-hour removal and a two-hour stump grind are one estimate across two placements. null when nobody has estimated the work.
crewobject or nullnull means placed on the day with no crew yet ("pencilled").

data → needsAttention

FieldTypeNotes
scheduleIduuidThe placement, not the estimate.
estimateIduuid
estimateNumberstringFormatted, e.g. "JOB-0062".
estimateStatusstring
scheduledDatestringYYYY-MM-DD, the crew's local day.
serviceCategoriesstring[]Distinct catalogue categories across this estimate's lines. Also a query parameter on this endpoint.
districtIdsuuid[]Every named area this day's SITE falls in. Also a query parameter, and returned so a districtIds filter can be checked against the rows it gives back rather than taken on trust.
sortOrderintegerPosition within that crew's day, ascending. This is the drive order.
notestring or null
siteIduuid
siteLabelstring
latitudenumber or null
longitudenumber or null
totalCentsinteger
plannedHoursnumber or nullHow many of the estimate's hours are planned onto THIS day. A job spread over several days carries a share on each, and they need not be equal: a twelve-hour removal and a two-hour stump grind are one estimate across two placements. null when nobody has estimated the work.
crewobject or nullnull means placed on the day with no crew yet ("pencilled").

Example response 200

{
  "success": true,
  "message": "Scheduled work retrieved",
  "data": {
    "upcoming": [
      {
        "scheduleId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
        "estimateId": "e5d4c3b2-a190-4877-b6e5-d4c3b2a19087",
        "estimateNumber": "JOB-0062",
        "estimateStatus": "scheduled",
        "scheduledDate": "2026-09-08",
        "sortOrder": 1000,
        "note": null,
        "serviceCategories": [
          "pruning"
        ],
        "districtIds": [],
        "siteId": "3a7c9e21-5b48-4f0a-9d33-71c6ee204b18",
        "siteLabel": "3760 W Delap Rd",
        "latitude": 39.1912,
        "longitude": -86.5847,
        "totalCents": 122500,
        "plannedHours": 8,
        "crew": {
          "id": "d2f4a601-88b3-4c17-9e5a-3b7f0c1d2e94",
          "name": "Crew A",
          "color": "#2f855a",
          "isArchived": false
        }
      }
    ],
    "needsAttention": []
  }
}

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/schedule

Place accepted work on a crew and a day

Only accepted work can be placed, which is why POST /estimates/{estimateId}/accept comes first. Send the ids IN DRIVE ORDER: position on the day is assigned in the order given, so the array is the route and no separate ordering call exists. The answer is 200 with a per-estimate report even when some failed, because a batch is a gesture rather than an atomic unit and the ones that landed stay landed. Send an Idempotency-Key: each estimate derives a stable id from it, so retrying the same batch replays estimate by estimate and places nothing twice. Resend the SAME key and body and the boundary replays the original answer byte for byte, with an Idempotent-Replay: true header and a 200 rather than the original status: that header is how you tell it worked from it already worked. Place the same estimate on the same day under a NEW key and its row reads skipped. Nothing is placed twice either way.

Scope: schedule: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
estimateIdsrequireduuid[]IN THE ORDER THEY SHOULD BE DRIVEN. Position on the day is assigned in the order given, so this array is the route. Duplicates are collapsed.
crewIdrequireduuid or nullThe crew to place them on, or null to pencil the day in without one. Read GET /crews for the id.
scheduledDaterequiredstring
notestring or null
plannedHoursnumberHours to place on this day, per estimate. OMIT to place everything that is still unplaced, which is what this endpoint has always done. Pass a smaller number to fill a day and leave the rest: a sixteen-hour removal placed at 8 keeps eight hours unscheduled, and the estimate keeps appearing in work still to be placed until they are all on a day.
lineItemIduuidWHICH SERVICE these days cover. Required for a program: a round's wait is counted from the day the PREVIOUS round was worked, so a placement that does not say which round it was leaves the next one with nothing to count from and it never becomes due. Ignored unless exactly one estimate is being placed, because a sold line belongs to one estimate. Read the line ids from GET /estimates/{id}.

Example request

{
  "estimateIds": [
    "e5d4c3b2-a190-4877-b6e5-d4c3b2a19087"
  ],
  "crewId": "d2f4a601-88b3-4c17-9e5a-3b7f0c1d2e94",
  "scheduledDate": "2026-09-08",
  "note": "Ellettsville cluster, north to south"
}

Each item in data.items

FieldTypeNotes
estimateIduuid
status"placed" | "skipped" | "failed"skipped means it was already on that day: a no-op, not a failure. failed means this one did not land and error says why.
errorstring or null

Example response 200

{
  "success": true,
  "message": "Work scheduled",
  "data": {
    "placedCount": 1,
    "failedCount": 0,
    "items": [
      {
        "estimateId": "e5d4c3b2-a190-4877-b6e5-d4c3b2a19087",
        "status": "placed",
        "error": null
      }
    ]
  }
}

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.
posthttps://app.treeinventory.ai/api/v1/schedule/bump

Move a crew's whole day forward

It rained. The day does not happen, and everything on it moves together. Unlike POST /schedule, which reports per estimate because a batch is a selection somebody made, this is ATOMIC: the whole day lands on the new date or nothing moves at all. A board showing half a crew tomorrow and half today is worse than one that did not move. days counts WORKING days, so 1 from a Friday is Monday. Read toDate off the response rather than computing it. THE BUMPED DAY IS ALWAYS EMPTY AFTERWARDS, and movedCount is everything that left it. One case does not land on toDate: a job spanning several days already holds that date, so its row steps on to the next free working day instead and comes back in landedLater with the date it actually reached. Report those separately, because a crew is going to that site on a different day from the rest of the load. Work that is finished or cancelled does not move: it is history. Send an Idempotency-Key and a retry replays the original answer instead of moving the day a second time. If a concurrent bump of an adjacent day lands work on your target while yours is running, nothing moves and you are told to try again.

Scope: schedule: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
crewIdrequireduuid or nullThe crew whose day is moving. null moves the unassigned board, which is a real board a dispatcher reads on its own.
fromDaterequiredstringThe day that is not happening, YYYY-MM-DD.
daysrequiredintegerHow many WORKING days forward. Weekends are skipped by counting, not by adding and nudging, so 1 from a Friday is Monday and 2 is Tuesday. Forward only: pulling work earlier is a different decision with different consequences, and nothing here does it.

Example request

{
  "crewId": "d2f4a601-88b3-4c17-9e5a-3b7f0c1d2e94",
  "fromDate": "2026-09-11",
  "days": 1
}

data

FieldTypeNotes
fromDatestring
toDatestringWhere the day landed. Compute it yourself only if you have to: this is the date the rows actually carry.
movedCountintegerEverything that left the bumped day, landedLater included. That day is always empty afterwards.
landedLaterobject[]Rows that moved, but not to toDate. A job spanning several days already holds the target date, so its row from the bumped day steps on to the next free working day instead. Report these separately: a crew is going to that site on a different day from the rest of the load.
orderPinnedbooleanTrue when the day you moved had a MANUAL running order and that order travelled with it. A pin is what holds an emergency at the front of a run, so a day carrying one arrives still pinned and the day it left is unpinned. Put it back on automatic with POST /routes/{crewId}/{date}/automatic when the day is normal.

data → landedLater

FieldTypeNotes
scheduleIduuid
jobIduuid
landedOnstringWhere this one actually went.
reason"job_already_had_that_day"

Example response 200

{
  "success": true,
  "message": "Day moved",
  "data": {
    "fromDate": "2026-09-11",
    "toDate": "2026-09-14",
    "movedCount": 6,
    "landedLater": [],
    "orderPinned": false
  }
}

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.
posthttps://app.treeinventory.ai/api/v1/schedule/reassign

Hand a crew's whole day to another crew

Somebody called in sick and the work still has to happen today. THE DATE DOES NOT CHANGE: this moves the crew, not the day. If the work should happen later instead, use POST /schedule/bump, and do not guess which one the person meant. Atomic, like the bump: the whole day changes hands or none of it does, because half a handover leaves two crews each believing the other has the afternoon. Either crew id may be null for the unassigned board, which is a real board on both sides. Arriving stops are appended after whatever the receiving crew already had, and both crew-days are re-routed. One case cannot move: a stop whose job ALREADY has the receiving crew that day has nowhere to go, since the date is fixed and merging the two rows would lose hours recorded per row. Those come back in notMoved and are STILL WITH THE ORIGINAL CREW, so do not report the handover as complete without naming them. Finished and cancelled work does not move. Send an Idempotency-Key.

Scope: schedule: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
fromCrewIdrequireduuid or nullThe crew that is not coming in. null is the unassigned board, which is a real board: work with a day but nobody on it yet.
toCrewIdrequireduuid or nullWho picks it up. null puts the day back on the unassigned board, which is what you do before you know who is covering.
daterequiredstringThe day, YYYY-MM-DD. It does not change.

Example request

{
  "fromCrewId": "d2f4a601-88b3-4c17-9e5a-3b7f0c1d2e94",
  "toCrewId": "9f1c7a20-5d84-4e13-9b62-3a7d5c8e4f10",
  "date": "2026-09-14"
}

data

FieldTypeNotes
datestring
fromCrewIduuid or null
toCrewIduuid or null
movedCountinteger
notMovedobject[]Stops STILL WITH THE ORIGINAL CREW, and why. Somebody has to decide what happens to these, so do not report the handover as complete without naming them. already_started: a crew member is clocked in on that stop right now. They are at the site, so it is the one job on the day that does not need covering, and moving it would leave the timesheet and the board disagreeing about who worked it. target_crew_already_on_that_job: the receiving crew already has that job that day, and there is nowhere else for the row to go — the date is fixed, and folding the two together would lose hours, which are recorded per row.
orderPinnedbooleanTrue when the outgoing crew's day had a MANUAL running order and the receiving crew's day inherited it. Without that, an emergency the outgoing crew was going to first would be reordered by the optimizer the moment the day changed hands.

data → notMoved

FieldTypeNotes
scheduleIduuid
jobIduuid
reason"target_crew_already_on_that_job" | "already_started"

Example response 200

{
  "success": true,
  "message": "Day handed over",
  "data": {
    "date": "2026-09-14",
    "fromCrewId": "d2f4a601-88b3-4c17-9e5a-3b7f0c1d2e94",
    "toCrewId": "9f1c7a20-5d84-4e13-9b62-3a7d5c8e4f10",
    "movedCount": 5,
    "notMoved": [],
    "orderPinned": false
  }
}

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.
posthttps://app.treeinventory.ai/api/v1/schedule/emergency

A tree came down: put this job next

Storm work. estimateId is the id GET /schedule/unscheduled gives you, so a row off the queue goes straight in. The job goes to the front of what a crew has left to do today, and the day is PINNED to manual so the optimizer cannot re-sort it back into the middle of the run. position is 1-based and is NOT always 1: a stop the crew has already clocked on to stays in front of it, because nothing can go before work somebody is standing on. If the job is already on that day this moves it up the order instead of adding a second row, and says so in wasAlreadyOnTheDay. THE PART TO READ IS displaced: the emergency is going to happen, so something else is not, and that list is which. Those jobs are still on the day and nobody has been told, so hand the list back for phone calls rather than treating them as cancelled. orderPinned means the day stays as you left it until somebody calls POST /routes/{crewId}/{date}/automatic. crewId may be null for the unassigned board. Send an Idempotency-Key.

Scope: schedule: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
estimateIdrequireduuidThe work that has to happen now. This is the estimateId that GET /schedule/unscheduled and GET /estimates return, so a row off the queue can be sent here unchanged.
crewIdrequireduuid or nullWho goes. null puts it on the unassigned board: somebody has to go and who is a second decision, which should not slow this down.
daterequiredstringThe day, YYYY-MM-DD.
plannedHoursnumber or nullHow long it will take. Omit or null when nobody knows yet, which is the normal state of a storm call taken over the phone. The day's arithmetic then falls back to a placeholder and says so.
notestring or nullWhat happened, for whoever reads the day. "Tree on the house" is the kind of thing a crew needs to see before they arrive.

Example request

{
  "estimateId": "e5d4c3b2-a190-4877-b6e5-d4c3b2a19087",
  "crewId": "d2f4a601-88b3-4c17-9e5a-3b7f0c1d2e94",
  "date": "2026-09-14",
  "plannedHours": 3.5,
  "note": "Tree on the house"
}

data

FieldTypeNotes
scheduleIduuid
estimateIduuid
crewIduuid or null
datestring
wasAlreadyOnTheDaybooleanTrue when the job was already on this day and only moved up the running order. Not a duplicate, and not an error: "go there next instead" is a real request.
positioninteger1-based position in the running order. NOT always 1. Work the crew has already clocked on to stays in front, because nothing can go before a stop somebody is standing on.
orderedScheduleIdsuuid[]
orderPinnedbooleanThe day is now manual, so the optimizer will not re-sort this stop out of position. False only for the unassigned board, which has no route to control. Switch it back with POST /routes/{crewId}/{date}/automatic once the day is normal again.
displacedobject[]WORK THAT NO LONGER FITS THE DAY. The emergency is going to happen, so something else is not, and this is which. These jobs are still ON the day: nothing has been unscheduled and nobody has been told. Read this list out and hand it back, because those customers need a phone call.
dayEndsAtMinutesinteger or nullWhen the last stop now finishes, minutes past midnight. null when the day carries no estimates at all, so that any claim about fitting would be a guess rather than a measurement.

data → displaced

FieldTypeNotes
scheduleIduuid
estimateIduuid
jobNumberinteger
addressLabelstring or null

Example response 200

{
  "success": true,
  "message": "Scheduled next",
  "data": {
    "scheduleId": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e",
    "estimateId": "e5d4c3b2-a190-4877-b6e5-d4c3b2a19087",
    "crewId": "d2f4a601-88b3-4c17-9e5a-3b7f0c1d2e94",
    "date": "2026-09-14",
    "wasAlreadyOnTheDay": false,
    "position": 1,
    "orderedScheduleIds": [
      "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e"
    ],
    "orderPinned": true,
    "displaced": [],
    "dayEndsAtMinutes": 1005
  }
}

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/schedule/unscheduled

List work waiting for a day

Agreed work that still has hours nobody has given a day to. This is the queue scheduling actually works from: an estimate lands here the moment it is accepted, and leaves when its hours are all placed. Paged rather than capped, because filtering a list that was silently truncated gives a confident wrong answer: total is the size of the whole set, so page with offset until you have seen it. Every field you would want to filter on is published, including serviceCategories, acceptedAt, districtIds and the site coordinates — and every one of them can also be passed as a query parameter, so you can narrow the set server-side and page through exactly what you asked for rather than filtering a page after it arrives. To find out WHERE a row should go, pass its id to GET /schedule/fit, which ranks every (crew, day) with a reason instead of returning only the top one.

Scope: schedule:read

Query parameters

FieldTypeNotes
limitintegerRows per page, 1 to 200.
offsetintegerRows to skip. Page with this until you have total rows: the order is total (oldest acceptance first, then estimate number), so pages do not repeat or skip.
qstringFree text over the site label and the job number. NOT the customer: unplaced work carries no customer name, and promising one here would be a search that silently never matches.
kindsarrayWhich kinds to include, comma-separated. Omit for all three. work_to_place is accepted work with no day, work_day is a placed crew-day, appointment is a visit.
categoriesarrayCatalogue categories, comma-separated, e.g. removal,plant_health. A POSITIVE filter: work with no catalogue-backed lines has no categories and does not match. Appointments sell nothing, so any value here excludes them.
crewIdsarrayCrew ids, comma-separated. Applies to placed crew-days only; it never drops appointments, which have no crew.
assigneeIdsarrayUser ids, comma-separated. Applies to appointments only.
minAgeDaysintegerAccepted at least this many WHOLE days ago. Work with no accepted date has no age and is excluded rather than counted as zero.
acceptedFromstringAccepted on or after this day.
acceptedTostringAccepted on or before this day, inclusive.
scheduledFromstringPlaced on or after this day. Unplaced work is excluded: it is not on the calendar, so it is not an answer to a question about the calendar.
scheduledTostringPlaced on or before this day, inclusive.
dueOnlystringOnly work whose next program round is due or overdue. A job that is not part of a program never matches.
estimateIdsarrayExactly these estimates, comma-separated. For a caller that already has the ids and wants their current state rather than a page of the queue.
districtIdsarrayDistrict ids, comma-separated. Matching ANY of them is enough. A site can be in more than one district, and work whose site is in none never matches.
bboxstringAn area, as west,south,east,north in degrees — the corners of a box on the map. Work with no coordinates is excluded, because a question about an area cannot be answered yes for something with no location. A box crossing the antimeridian (west > east) is supported.

Each item in data.items

FieldTypeNotes
estimateIduuid
estimateNumberstringFormatted, e.g. "JOB-0062".
siteIduuid
siteLabelstring or nullThe site's nickname if it has one, otherwise its street.
acceptedAtstring or nullWhen the customer said yes. The only urgency signal this list carries: sort by it to find what has been waiting longest.
estimatedHoursnumber or nullHow long the work is expected to take, when somebody has estimated it. null means nobody has. It does NOT mean zero, and a planner that reads it as zero will overfill a day.
remainingHoursnumber or nullHow much of estimatedHours is still unplaced. Equal to it for work with no days yet, smaller once some has been placed, null when there is nothing to subtract from.
expectedDaysinteger or nullDuration hint, not a constraint.
latitudenumber or nullnull means the site was never geocoded. It is listed anyway.
longitudenumber or null
serviceCategoriesstring[]Distinct catalogue categories across this estimate's lines, so the list can be filtered by what the work actually is. Empty is legal and means no catalogue-backed lines, never no services.
districtIdsuuid[]Every named area this estimate's SITE falls in. A site can be in more than one: districts are a filing convention, not a partition. Empty when the site has no coordinates or falls outside every district. Returned as well as filterable, so a districtIds filter can be checked against the rows it gives back.
dueRoundobject or nullThe program round whose window is open. Null on ordinary work, which is most of it. A round becomes due a set number of days after the PREVIOUS round was worked, so this is the field that tells you a six-visit plant health care plan needs its next visit booked. dueOnly=true filters to exactly the rows where this is not null.

Example response 200

{
  "success": true,
  "message": "Work to place retrieved",
  "data": {
    "items": [
      {
        "estimateId": "e5d4c3b2-a190-4877-b6e5-d4c3b2a19087",
        "estimateNumber": "JOB-0062",
        "siteId": "3a7c9e21-5b48-4f0a-9d33-71c6ee204b18",
        "siteLabel": "3760 W Delap Rd",
        "acceptedAt": "2026-09-01T01:47:21.570Z",
        "estimatedHours": 6,
        "remainingHours": 6,
        "expectedDays": 1,
        "latitude": 39.1912,
        "longitude": -86.5847,
        "serviceCategories": [
          "pruning"
        ],
        "districtIds": [],
        "dueRound": null
      }
    ],
    "total": 14,
    "nextOffset": null
  }
}

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.
gethttps://app.treeinventory.ai/api/v1/districts

List the named areas

The areas this org files work under. districtIds is a filter on the schedule endpoints, and this is where its values come from: a filter whose values cannot be looked up is a filter nobody can use. A site can fall in more than one district — they are a filing convention, not a partition — and work whose site has no coordinates falls in none.

Scope: schedule:read

Each item in data.items

FieldTypeNotes
iduuid
namestring
boundaryunknown[][]A closed ring of [longitude, latitude] pairs, GeoJSON axis order. The ring closes implicitly, so the first point is not repeated.
createdAtstring

Example response 200

{
  "success": true,
  "message": "Districts retrieved",
  "data": {
    "items": [
      {
        "id": "7c2f5a91-3e64-4b7d-8a15-2f9c6d4e8b03",
        "name": "North Valley",
        "boundary": [
          [
            -115.4,
            36.1
          ],
          [
            -115,
            36.1
          ],
          [
            -115,
            36.35
          ],
          [
            -115.4,
            36.35
          ]
        ],
        "createdAt": "2026-09-03T04:00:00.000Z"
      }
    ]
  }
}

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/districts

Draw a named area

Give an area a name so work in it can be pulled by that name. The boundary is a closed ring of [longitude, latitude] points and the ring closes implicitly, so the four corners of a bounding box are a valid district: the same bbox you filter with can be saved as one. Three points is the minimum, because two is a line and a line contains nothing. Names are unique per org, since two districts called North is a filter nobody can read.

Scope: schedule: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
namerequiredstringUnique within the org.
boundaryrequiredunknown[][]At least three [longitude, latitude] points. Three is the fewest that is an area; two is a line and a line contains nothing.

Example request

{
  "name": "North Valley",
  "boundary": [
    [
      -115.4,
      36.1
    ],
    [
      -115,
      36.1
    ],
    [
      -115,
      36.35
    ],
    [
      -115.4,
      36.35
    ]
  ]
}

data

FieldTypeNotes
iduuid
namestring
boundaryunknown[][]A closed ring of [longitude, latitude] pairs, GeoJSON axis order. The ring closes implicitly, so the first point is not repeated.
createdAtstring

Example response 201

{
  "success": true,
  "message": "District created",
  "data": {
    "id": "7c2f5a91-3e64-4b7d-8a15-2f9c6d4e8b03",
    "name": "North Valley",
    "boundary": [
      [
        -115.4,
        36.1
      ],
      [
        -115,
        36.1
      ],
      [
        -115,
        36.35
      ],
      [
        -115.4,
        36.35
      ]
    ],
    "createdAt": "2026-09-03T04:00:00.000Z"
  }
}

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/schedule/fit

Where would this work fit

Rank (crew, day) pairs for work you are about to place, with the reason for each. This is the question a dispatcher actually asks, and it was previously answerable only by a person looking at the board: you could read the backlog and read the schedule and still not know where to put anything. Pass estimateIds rather than hours wherever you can, because it takes the size from the estimates themselves including the fallback this product uses for work nobody has estimated. Order is days it fits, nearest first, then soonest: proximity leads because the real cost is windshield time, and a day with room across town is worse than a slightly tighter day four minutes away. Days it does NOT fit are ranked last and never dropped, because nothing fits is an answer. **This endpoint ranks and never places.** It has no side effects; placing one of these is a separate, deliberate POST /schedule. A wrong ranking costs a second look, a wrong placement costs somebody’s Tuesday.

Scope: schedule:read

Query parameters

FieldTypeNotes
fromstringFirst day to rank. Defaults to today, UTC.
daysintegerHow many WORKING days forward to rank. Weekends are skipped, so 10 days reaches a fortnight out.
estimateIdsarrayThe unplaced work you are trying to place, comma-separated. PREFER THIS over hours: it takes the size from the estimates themselves, including this product's own fallback for work nobody has estimated, which you would otherwise have to reproduce. An id that is not waiting to be placed is a 404, not an empty ranking.
hoursnumberHow long the work takes, for reasoning about work that does not exist yet. Ignored when estimateIds is given.
latnumber
lngnumberWhere the work is, so days near existing stops rank higher. Without it every day is judged on room alone, which is a worse answer, not an error.
crewIdsarrayOnly rank these crews. Omit for all of them.

data

FieldTypeNotes
fitsobject[]Best first: days it fits, nearest first, then soonest. Proximity leads because the real cost is windshield time. Days it does NOT fit are ranked last and never dropped, because nothing fits is an answer.
needobject

data → fits

FieldTypeNotes
crewIduuid
crewNamestring
datestringYYYY-MM-DD.
fitsbooleanWhether the work finishes inside this crew's own working day. False does NOT mean refused: placing it is still allowed and the API will accept it. It means somebody is working late.
remainingMinutesnumberRoom left AFTER this work. Negative means it runs over.
nearestMetersnumber or nullCrow-flies distance to that crew-day's nearest existing stop. null when the day is empty: an empty day has room but no gravity, so it can never be on the way to anything.
bookedMinutesnumberHow full the day already is.
stopsinteger
reasonstringThe same sentence the dispatcher's board shows, so an agent and a human are told the same thing in the same words.

data → need

FieldTypeNotes
minutesnumber
estimatedbooleanFalse when the size is this product's fallback rather than somebody's estimate. Plan around a false here and you are planning around a number nobody entered.

Example response 200

{
  "success": true,
  "message": "Capacity ranked",
  "data": {
    "fits": [
      {
        "crewId": "0f9d2c1e-6b3a-4d5e-9f21-7c8b4a1d2e35",
        "crewName": "Tree Crew",
        "date": "2026-09-10",
        "fits": true,
        "remainingMinutes": 144,
        "nearestMeters": 3219,
        "bookedMinutes": 336,
        "stops": 3,
        "reason": "2.4h would be left, nearest stop 2 mi"
      }
    ],
    "need": {
      "minutes": 240,
      "estimated": true
    }
  }
}

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.
patchhttps://app.treeinventory.ai/api/v1/estimates/{estimateId}/schedule/{scheduleId}

Move a placement

Change the day, the crew, or the note on one placement, in place. Send only what you are changing: omitting crewId leaves the crew alone and sending null takes the work off a crew, and those are different requests. A placement that would collide with the same estimate already being on that crew's day is refused and nothing moves.

Scope: schedule:write

Path parameters

FieldTypeNotes
estimateIdrequireduuidThe estimate the placement belongs to.
scheduleIdrequireduuidThe placement, from GET /schedule.

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
scheduledDatestringMove it to this day. Omit to leave the day alone.
crewIduuid or nullHand it to this crew, or null to take it off a crew and leave it pencilled on the day. Omit to leave the crew alone. The three are different, so omitting and sending null do NOT mean the same thing.
notestring or null

Example request

{
  "scheduledDate": "2026-09-09",
  "crewId": "d2f4a601-88b3-4c17-9e5a-3b7f0c1d2e94"
}

data

FieldTypeNotes
scheduleIduuid
estimateIduuid
crewIduuid or null
scheduledDatestring
sortOrderinteger
notestring or null
createdAtstring
updatedAtstring

Example response 200

{
  "success": true,
  "message": "Placement moved",
  "data": {
    "scheduleId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "estimateId": "e5d4c3b2-a190-4877-b6e5-d4c3b2a19087",
    "crewId": "d2f4a601-88b3-4c17-9e5a-3b7f0c1d2e94",
    "scheduledDate": "2026-09-09",
    "sortOrder": 1000,
    "note": null,
    "createdAt": "2026-09-01T04:10:02.118Z",
    "updatedAt": "2026-09-01T04:31:55.204Z"
  }
}

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.
deletehttps://app.treeinventory.ai/api/v1/estimates/{estimateId}/schedule/{scheduleId}

Take work off the board

Removes one placement. The estimate is not touched and nothing is cancelled. If it was the last placement the estimate holds, the estimate goes back to accepted and revertedToAccepted says so, which means it reappears in GET /schedule/unscheduled waiting for a day.

Scope: schedule:write

Path parameters

FieldTypeNotes
estimateIdrequireduuidThe estimate the placement belongs to.
scheduleIdrequireduuidThe placement to remove.

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
removedtrue
revertedToAcceptedbooleantrue when that was the estimate's last placement and it went back to accepted. It is agreed work again, waiting for a day, and it reappears in GET /schedule/unscheduled.

Example response 200

{
  "success": true,
  "message": "Placement removed",
  "data": {
    "removed": true,
    "revertedToAccepted": true
  }
}

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.