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.
- get /schedule List placed work
- post /schedule Place accepted work on a crew and a day
- post /schedule/bump Move a crew's whole day forward
- post /schedule/reassign Hand a crew's whole day to another crew
- post /schedule/emergency A tree came down: put this job next
- get /schedule/unscheduled List work waiting for a day
- get /districts List the named areas
- post /districts Draw a named area
- get /schedule/fit Where would this work fit
- patch /estimates/{estimateId}/schedule/{scheduleId} Move a placement
- delete /estimates/{estimateId}/schedule/{scheduleId} Take work off the board
https://app.treeinventory.ai/api/v1/scheduleList 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
| Field | Type | Notes |
|---|---|---|
| from | string | Placements on or after this day. Defaults to today, UTC. |
| to | string | Placements 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. |
| crewId | string | |
| q | string | Free 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. |
| kinds | array | Which 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. |
| categories | array | Catalogue 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. |
| crewIds | array | Crew ids, comma-separated. Applies to placed crew-days only; it never drops appointments, which have no crew. |
| assigneeIds | array | User ids, comma-separated. Applies to appointments only. |
| minAgeDays | integer | Accepted at least this many WHOLE days ago. Work with no accepted date has no age and is excluded rather than counted as zero. |
| acceptedFrom | string | Accepted on or after this day. |
| acceptedTo | string | Accepted on or before this day, inclusive. |
| scheduledFrom | string | Placed 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. |
| scheduledTo | string | Placed on or before this day, inclusive. |
| dueOnly | string | Only work whose next program round is due or overdue. A job that is not part of a program never matches. |
| estimateIds | array | Exactly these estimates, comma-separated. For a caller that already has the ids and wants their current state rather than a page of the queue. |
| districtIds | array | District 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. |
| bbox | string | An 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
| Field | Type | Notes |
|---|---|---|
| upcoming | ScheduledWork[] | |
| needsAttention | ScheduledWork[] | 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
| Field | Type | Notes |
|---|---|---|
| scheduleId | uuid | The placement, not the estimate. |
| estimateId | uuid | |
| estimateNumber | string | Formatted, e.g. "JOB-0062". |
| estimateStatus | string | |
| scheduledDate | string | YYYY-MM-DD, the crew's local day. |
| serviceCategories | string[] | Distinct catalogue categories across this estimate's lines. Also a query parameter on this endpoint. |
| districtIds | uuid[] | 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. |
| sortOrder | integer | Position within that crew's day, ascending. This is the drive order. |
| note | string or null | |
| siteId | uuid | |
| siteLabel | string | |
| latitude | number or null | |
| longitude | number or null | |
| totalCents | integer | |
| plannedHours | number or null | How 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. |
| crew | object or null | null means placed on the day with no crew yet ("pencilled"). |
data → needsAttention
| Field | Type | Notes |
|---|---|---|
| scheduleId | uuid | The placement, not the estimate. |
| estimateId | uuid | |
| estimateNumber | string | Formatted, e.g. "JOB-0062". |
| estimateStatus | string | |
| scheduledDate | string | YYYY-MM-DD, the crew's local day. |
| serviceCategories | string[] | Distinct catalogue categories across this estimate's lines. Also a query parameter on this endpoint. |
| districtIds | uuid[] | 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. |
| sortOrder | integer | Position within that crew's day, ascending. This is the drive order. |
| note | string or null | |
| siteId | uuid | |
| siteLabel | string | |
| latitude | number or null | |
| longitude | number or null | |
| totalCents | integer | |
| plannedHours | number or null | How 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. |
| crew | object or null | null 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
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/schedulePlace 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
| 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 |
|---|---|---|
| estimateIdsrequired | uuid[] | 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. |
| crewIdrequired | uuid or null | The crew to place them on, or null to pencil the day in without one. Read GET /crews for the id. |
| scheduledDaterequired | string | |
| note | string or null | |
| plannedHours | number | Hours 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. |
| lineItemId | uuid | WHICH 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
| Field | Type | Notes |
|---|---|---|
| estimateId | uuid | |
| 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. |
| error | string 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
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/schedule/bumpMove 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
| 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 |
|---|---|---|
| crewIdrequired | uuid or null | The crew whose day is moving. null moves the unassigned board, which is a real board a dispatcher reads on its own. |
| fromDaterequired | string | The day that is not happening, YYYY-MM-DD. |
| daysrequired | integer | How 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
| Field | Type | Notes |
|---|---|---|
| fromDate | string | |
| toDate | string | Where the day landed. Compute it yourself only if you have to: this is the date the rows actually carry. |
| movedCount | integer | Everything that left the bumped day, landedLater included. That day is always empty afterwards. |
| landedLater | object[] | 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. |
| orderPinned | boolean | True 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
| Field | Type | Notes |
|---|---|---|
| scheduleId | uuid | |
| jobId | uuid | |
| landedOn | string | Where 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
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/schedule/reassignHand 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
| 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 |
|---|---|---|
| fromCrewIdrequired | uuid or null | The 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. |
| toCrewIdrequired | uuid or null | Who picks it up. null puts the day back on the unassigned board, which is what you do before you know who is covering. |
| daterequired | string | The 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
| Field | Type | Notes |
|---|---|---|
| date | string | |
| fromCrewId | uuid or null | |
| toCrewId | uuid or null | |
| movedCount | integer | |
| notMoved | object[] | 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. |
| orderPinned | boolean | True 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
| Field | Type | Notes |
|---|---|---|
| scheduleId | uuid | |
| jobId | uuid | |
| 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
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/schedule/emergencyA 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
| 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 |
|---|---|---|
| estimateIdrequired | uuid | The 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. |
| crewIdrequired | uuid or null | Who goes. null puts it on the unassigned board: somebody has to go and who is a second decision, which should not slow this down. |
| daterequired | string | The day, YYYY-MM-DD. |
| plannedHours | number or null | How 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. |
| note | string or null | What 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
| Field | Type | Notes |
|---|---|---|
| scheduleId | uuid | |
| estimateId | uuid | |
| crewId | uuid or null | |
| date | string | |
| wasAlreadyOnTheDay | boolean | True 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. |
| position | integer | 1-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. |
| orderedScheduleIds | uuid[] | |
| orderPinned | boolean | The 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. |
| displaced | object[] | 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. |
| dayEndsAtMinutes | integer or null | When 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
| Field | Type | Notes |
|---|---|---|
| scheduleId | uuid | |
| estimateId | uuid | |
| jobNumber | integer | |
| addressLabel | string 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
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/schedule/unscheduledList 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
| Field | Type | Notes |
|---|---|---|
| limit | integer | Rows per page, 1 to 200. |
| offset | integer | Rows 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. |
| q | string | Free 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. |
| kinds | array | Which 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. |
| categories | array | Catalogue 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. |
| crewIds | array | Crew ids, comma-separated. Applies to placed crew-days only; it never drops appointments, which have no crew. |
| assigneeIds | array | User ids, comma-separated. Applies to appointments only. |
| minAgeDays | integer | Accepted at least this many WHOLE days ago. Work with no accepted date has no age and is excluded rather than counted as zero. |
| acceptedFrom | string | Accepted on or after this day. |
| acceptedTo | string | Accepted on or before this day, inclusive. |
| scheduledFrom | string | Placed 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. |
| scheduledTo | string | Placed on or before this day, inclusive. |
| dueOnly | string | Only work whose next program round is due or overdue. A job that is not part of a program never matches. |
| estimateIds | array | Exactly these estimates, comma-separated. For a caller that already has the ids and wants their current state rather than a page of the queue. |
| districtIds | array | District 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. |
| bbox | string | An 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
| Field | Type | Notes |
|---|---|---|
| estimateId | uuid | |
| estimateNumber | string | Formatted, e.g. "JOB-0062". |
| siteId | uuid | |
| siteLabel | string or null | The site's nickname if it has one, otherwise its street. |
| acceptedAt | string or null | When the customer said yes. The only urgency signal this list carries: sort by it to find what has been waiting longest. |
| estimatedHours | number or null | How 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. |
| remainingHours | number or null | How 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. |
| expectedDays | integer or null | Duration hint, not a constraint. |
| latitude | number or null | null means the site was never geocoded. It is listed anyway. |
| longitude | number or null | |
| serviceCategories | string[] | 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. |
| districtIds | uuid[] | 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. |
| dueRound | object or null | The 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
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/districtsList 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
| Field | Type | Notes |
|---|---|---|
| id | uuid | |
| name | string | |
| boundary | unknown[][] | A closed ring of [longitude, latitude] pairs, GeoJSON axis order. The ring closes implicitly, so the first point is not repeated. |
| createdAt | string |
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
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/districtsDraw 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
| 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 |
|---|---|---|
| namerequired | string | Unique within the org. |
| boundaryrequired | unknown[][] | 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
| Field | Type | Notes |
|---|---|---|
| id | uuid | |
| name | string | |
| boundary | unknown[][] | A closed ring of [longitude, latitude] pairs, GeoJSON axis order. The ring closes implicitly, so the first point is not repeated. |
| createdAt | string |
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
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/schedule/fitWhere 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
| Field | Type | Notes |
|---|---|---|
| from | string | First day to rank. Defaults to today, UTC. |
| days | integer | How many WORKING days forward to rank. Weekends are skipped, so 10 days reaches a fortnight out. |
| estimateIds | array | The 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. |
| hours | number | How long the work takes, for reasoning about work that does not exist yet. Ignored when estimateIds is given. |
| lat | number | |
| lng | number | Where 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. |
| crewIds | array | Only rank these crews. Omit for all of them. |
data
| Field | Type | Notes |
|---|---|---|
| fits | object[] | 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. |
| need | object |
data → fits
| Field | Type | Notes |
|---|---|---|
| crewId | uuid | |
| crewName | string | |
| date | string | YYYY-MM-DD. |
| fits | boolean | Whether 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. |
| remainingMinutes | number | Room left AFTER this work. Negative means it runs over. |
| nearestMeters | number or null | Crow-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. |
| bookedMinutes | number | How full the day already is. |
| stops | integer | |
| reason | string | The same sentence the dispatcher's board shows, so an agent and a human are told the same thing in the same words. |
data → need
| Field | Type | Notes |
|---|---|---|
| minutes | number | |
| estimated | boolean | False 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
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}/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
| Field | Type | Notes |
|---|---|---|
| estimateIdrequired | uuid | The estimate the placement belongs to. |
| scheduleIdrequired | uuid | The placement, from GET /schedule. |
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 |
|---|---|---|
| scheduledDate | string | Move it to this day. Omit to leave the day alone. |
| crewId | uuid or null | Hand 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. |
| note | string or null |
Example request
{
"scheduledDate": "2026-09-09",
"crewId": "d2f4a601-88b3-4c17-9e5a-3b7f0c1d2e94"
}data
| Field | Type | Notes |
|---|---|---|
| scheduleId | uuid | |
| estimateId | uuid | |
| crewId | uuid or null | |
| scheduledDate | string | |
| sortOrder | integer | |
| note | string or null | |
| createdAt | string | |
| updatedAt | string |
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
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}/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
| Field | Type | Notes |
|---|---|---|
| estimateIdrequired | uuid | The estimate the placement belongs to. |
| scheduleIdrequired | uuid | The placement to remove. |
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 |
|---|---|---|
| removed | true | |
| revertedToAccepted | boolean | true 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
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.