# Schedule & crews

The schedule is made of **work orders**: one scheduled visit of a crew to a job. A visit has a start date, a length in working days, an optional start and finish time, a crew, and the people and equipment on it. A job can have many visits (phases, return trips, repeat service).

These endpoints run the same rules as the Schedule page in TruTec: crew-day locks, double-booking checks, skipped days and work weeks, and the undo history all apply.

## How the schedule works

**Edits are drafts until you publish.** Creating, moving or re-crewing a visit changes the office schedule only. Crews see the change on their phones after `POST /v1/schedule/publish`, the same as pressing **Publish** in TruTec. Publishing is a one-way step: it sends the visits to crews and cannot be taken back. Moving a visit that was already published makes a new draft change that the next publish sends.

**Who the API acts as.** Writes are made as the person who created the API key. Their own TruTec permissions apply: if they can only view the schedule, schedule writes return `403` `view_only`. Changes show in TruTec's history under their name. If that person has left the workspace, been suspended or switched to another workspace, writes return `403` `Key owner unavailable`; create a new key. Partner keys act as the workspace owner.

**Operations must be on.** Scheduling, crews and timesheets are part of TruTec's operations tools. If they are not turned on for the workspace, writes return `403` `Operations not enabled`.

Dates are `YYYY-MM-DD` and times are 24-hour local times (`"07:30"`) in the workspace's time zone. Timestamps are UTC with a `Z` suffix.

!!! note
    Partner keys that act on behalf of several workspaces must also pass `firm_id` (query string on reads, JSON body on writes).

## The work order object

| Field | Type | Description |
|---|---|---|
| `id` | integer | Work order id. |
| `job_id`, `job` | integer, object | The job, with `{id, name}`. |
| `title` | string | What the crew sees, for example "Sealcoat front lot". |
| `status` | string | `unscheduled`, `scheduled`, `in_progress`, `completed` or `cancelled`. |
| `is_hold` | boolean | A tentative "hold a date" block. Holds never publish. |
| `scheduled_date` | string or null | First working day. Null while unscheduled. |
| `work_dates` | array | Every working day the visit covers, after skipped days, days off and the crew's work week. |
| `estimated_days` | number or null | Length in working days. |
| `start_time`, `end_time` | string or null | Local times. `ends_next_day` is true for a finish after midnight. |
| `skip_dates` | array | Days this visit skips. |
| `work_weekdays` | array | Weekdays the visit may land on, 1 (Monday) to 7 (Sunday). Empty means any working day. |
| `crew_id`, `crew` | integer, object | The crew, with `{id, name}`. |
| `foreman` | object or null | `{id, name}`. |
| `people` | array | Who is on the visit: the crew's members plus or minus people moved on or off it. Each has a `person_key` and `name`. |
| `quantity`, `unit` | number, string | How much work, for example `42000` `SF`. |
| `completed_quantity`, `completed_unit`, `completion_note` | | What the crew reported. |
| `scope`, `service_types` | string, array | Scope text and the types of work folded into this visit. |
| `address`, `latitude`, `longitude` | | Where the crew goes. |
| `notes`, `gate_code`, `staging_notes` | string or null | Dispatch notes for the crew. |
| `color`, `sequence` | string, integer | Calendar color and the order within the crew's day. |
| `equipment` | array | `{id, name}` for each machine on the visit. |
| `estimate_id`, `estimate_service_id` | integer or null | The estimate line the visit was scheduled from. |
| `customer_notify` | string or null | `none`, `email`, `sms` or `both`. Null follows the workspace default. |
| `customer_notified_at`, `customer_confirmed_at` | string or null | When the customer was told, and when they confirmed. |
| `dispatched_at`, `completed_at` | string or null | When the packet was sent, and when the visit was finished. |
| `has_unpublished_changes` | boolean | The next publish would send this visit. |
| `created_at`, `updated_at` | string | UTC timestamps. |

## Work orders

### List work orders

`GET /v1/work-orders`

**Permission:** Read jobs

Returns visits ordered by date (unscheduled last). With a date range, a multi-day visit is included when any of its working days falls in the range. With `updated_after`, results are sorted by `updated_at`, oldest first.

| Name | Type | Required | Description |
|---|---|---|---|
| `start_date`, `end_date` | string | No | Date range, up to 366 days. |
| `crew_id` | integer | No | Only this crew's visits. |
| `unassigned` | boolean | No | `true` for visits with no crew. |
| `job_id` | integer | No | Only this job's visits. |
| `status` | string | No | One status, or several separated by commas. |
| `include_holds` | boolean | No | Include "hold a date" blocks. Default `false`. |
| `updated_after` | string | No | ISO 8601. Only visits changed after it. |
| `limit`, `offset` | integer | No | Page size (default 50, maximum 200) and rows to skip. |

```bash
curl "https://trutec.ai/api/v1/work-orders?start_date=2026-11-02&end_date=2026-11-08&crew_id=18" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "data": [
    {
      "id": 4120,
      "job_id": 70412,
      "job": { "id": 70412, "name": "Northgate Plaza reseal" },
      "title": "Sealcoat front lot",
      "status": "scheduled",
      "is_hold": false,
      "phase": null,
      "scheduled_date": "2026-11-03",
      "work_dates": ["2026-11-03", "2026-11-04"],
      "estimated_days": 2.0,
      "start_time": "07:00",
      "end_time": "15:30",
      "ends_next_day": false,
      "skip_dates": [],
      "work_weekdays": [],
      "crew_id": 18,
      "crew": { "id": 18, "name": "Seal Crew 1" },
      "foreman": { "id": 344, "name": "Crew Lead" },
      "people": [
        { "person_key": "person:51", "name": "Crew Lead" },
        { "person_key": "name:laborer 2", "name": "Laborer 2" }
      ],
      "quantity": 42000.0,
      "unit": "SF",
      "completed_quantity": null,
      "completed_unit": null,
      "completion_note": null,
      "scope": "Clean, crack fill and two coats of sealer on the front lot.",
      "service_types": [],
      "address": "1200 Commerce Dr, Austin, TX 78701",
      "latitude": 30.2711,
      "longitude": -97.7437,
      "notes": "Gate opens at 6:45",
      "gate_code": "4412",
      "staging_notes": "Stage the rig behind building C",
      "color": null,
      "sequence": 0,
      "equipment": [{ "id": 902, "name": "Seal rig 2" }],
      "estimate_id": 9840,
      "estimate_service_id": 55120,
      "customer_notify": "email",
      "customer_notified_at": "2026-10-28T16:02:11Z",
      "customer_confirmed_at": null,
      "dispatched_at": null,
      "completed_at": null,
      "created_at": "2026-10-20T15:44:02Z",
      "updated_at": "2026-10-28T16:02:11Z",
      "has_unpublished_changes": false
    }
  ],
  "pagination": { "total": 1, "limit": 50, "offset": 0 }
}
```

**Errors:** `400` for a bad date, a range over 366 days, or an unknown `status`.

### Get a work order

`GET /v1/work-orders/{id}`

**Permission:** Read jobs

Returns one work order. A work order in another workspace returns `404` `Work order not found`.

### Create a work order

`POST /v1/work-orders`

**Permission:** Write jobs

Adds a visit. There are two ways:

- **New work on a job:** send `job_id` and `title`.
- **Queued work from a won estimate:** send a `queue_id` from [List the queue](#list-the-queue) (or `estimate_service_id`). TruTec builds the visit from the estimate line (title, quantity, scope, equipment) exactly as when you drag a queue card onto the board. The job must be in a stage that can be scheduled.

Add `crew_id` and `scheduled_date` to place it at the same time; without them the visit waits in the unscheduled queue. The visit is a draft until you publish.

| Name | Type | Required | Description |
|---|---|---|---|
| `job_id` | integer | Yes, unless `queue_id` | The job. |
| `title` | string | Yes, unless `queue_id` | What the crew sees. |
| `queue_id` | string | No | From the queue, for example `es-55120` or `job-70412`. |
| `crew_id` | integer | No | One of the workspace's active crews. |
| `scheduled_date` | string | No | First working day. |
| `estimated_days` | number | No | 0.5 to 365. |
| `work_dates` | array | No | Exact working days instead of a start date and length (1 to 120 dates). |
| `start_time`, `end_time` | string | No | Local times. A finish at or before the start ends the next day. |
| `phase`, `notes`, `unit`, `gate_code`, `staging_notes` | string | No | Details for the crew. |
| `color` | string | No | `#rrggbb`. |
| `foreman_user_id` | integer | No | A user in the workspace. |
| `customer_notify` | string | No | `none`, `email`, `sms` or `both`. Alerts go out when the visit is published. |
| `equipment_ids` | array | No | Equipment ids from your catalog. |
| `skip_dates` | array | No | Days to skip. |
| `work_weekdays` | array | No | 1 (Monday) to 7 (Sunday). |

```bash
curl -X POST "https://trutec.ai/api/v1/work-orders" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "job_id": 70412,
    "title": "Sealcoat front lot",
    "crew_id": 18,
    "scheduled_date": "2026-11-03",
    "estimated_days": 2,
    "start_time": "07:00",
    "notes": "Gate opens at 6:45"
  }'
```

Returns `201` with the work order (see the object above).

**Errors:**

- `400` for a missing `title` or `job_id`, a malformed value, or an unknown field.
- `404` when the job, crew or queue item is not in your workspace.
- `409` `crew_day_locked` when that crew day is locked, or a crew rule (for example a required co-worker) blocks the assignment; the response lists the `locks` or `violations`.
- If the visit was created but could not be placed, the error includes `work_order_id`: the visit exists in the queue, so fix the request and `PATCH` it instead of creating another.

### Update or reschedule a work order

`PATCH /v1/work-orders/{id}`

**Permission:** Write jobs

Changes only the fields you send. To move a visit, send a new `scheduled_date` and, if it changes, `crew_id`. Send `"scheduled_date": null` to return it to the unscheduled queue. A move of a published visit can carry a `reason` (for example "Rain"), which TruTec keeps with the change.

Accepts every field from **Create** except `job_id` and `queue_id`, plus `reason`. Status is not editable here; use the actions below.

```bash
curl -X PATCH "https://trutec.ai/api/v1/work-orders/4120" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "scheduled_date": "2026-11-05", "reason": "Rain" }'
```

Returns `200` with the updated work order. Errors are the same as **Create**.

### Delete a work order

`DELETE /v1/work-orders/{id}`

**Permission:** Write jobs and Delete records

Removes a visit and frees its crew days. Returns `204`. A visit with recorded crew time or workflow history returns `409` `workflow_history_recorded`; cancel it instead.

### Complete, reopen, cancel or unschedule

`POST /v1/work-orders/{id}/complete`

`POST /v1/work-orders/{id}/reopen`

`POST /v1/work-orders/{id}/cancel`

`POST /v1/work-orders/{id}/unschedule`

**Permission:** Write jobs

| Action | What it does |
|---|---|
| `complete` | Marks the visit complete. Send `{"whole_job": true}` to finish every open visit on the job; the response lists the others in `also_completed_ids`. |
| `reopen` | Puts a completed visit back to scheduled (or unscheduled if it has no date). |
| `cancel` | Cancels the visit. If the customer was already told about it, they get a cancellation alert, as in TruTec. |
| `unschedule` | Returns the whole visit to the queue. Send `{"date": "2026-11-04"}` to drop only that working day. |

Each returns `200` with the work order. Completed visits and holds cannot be unscheduled (`400`).

### Put a person on a visit, or take them off

`POST /v1/work-orders/{id}/people`

**Permission:** Write jobs

Moves one person onto or off a visit without changing the crew, the same as dragging a person on the Dispatch board. Someone already on another visit that day is moved off it (`moved_from_work_order_id`). By default the change also applies to later visits of the same estimate line (`future_work_orders_changed`); send `carry_forward: false` for this visit only. People changes reach crews right away and do not need a publish.

| Name | Type | Required | Description |
|---|---|---|---|
| `action` | string | Yes | `assign` or `remove`. |
| `user_id` | integer | One of these | A crew member with a TruTec login. |
| `person_key` | string | One of these | From [List people](#list-people) or the visit's `people`. |
| `date` | string | No | The day you are changing, for a multi-day visit. Default today. |
| `just_this_day` | boolean | No | Change only `date` on a multi-day visit. |
| `carry_forward` | boolean | No | Default `true`. |

```bash
curl -X POST "https://trutec.ai/api/v1/work-orders/4120/people" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "action": "assign", "user_id": 377, "date": "2026-11-03" }'
```

```json
{
  "data": { "id": 4120, "people": [ { "person_key": "person:51", "name": "Crew Lead" }, { "person_key": "name:pat rivera", "name": "Pat Rivera" } ] },
  "changed": true,
  "scope": "visit",
  "future_work_orders_changed": 0
}
```

(`data` is the full work order; shortened here.)

## Queue and publishing

### List the queue

`GET /v1/schedule/queue`

**Permission:** Read jobs

Work that is sold but not on the schedule yet: unscheduled work orders, plus estimate lines from won jobs that have no visit yet. Schedule a queue item with `POST /v1/work-orders` and its `queue_id`; an item that is already a work order has a `work_order_id` instead, so `PATCH` it.

| Name | Type | Required | Description |
|---|---|---|---|
| `job_id` | integer | No | Only this job's work. |
| `limit`, `offset` | integer | No | Paging. |

```json
{
  "data": [
    {
      "queue_id": "es-55120",
      "work_order_id": null,
      "kind": "estimate_service",
      "job_id": 70412,
      "job": { "id": 70412, "name": "Northgate Plaza reseal" },
      "title": "Sealcoating",
      "quantity": 42000.0,
      "unit": "SF",
      "estimated_days": 2.0,
      "suggested_crew_id": 18,
      "estimate_id": 9840,
      "estimate_service_id": 55120,
      "service_types": []
    }
  ],
  "pagination": { "total": 1, "limit": 50, "offset": 0 }
}
```

`kind` is `estimate_service`, `task`, `job` (a won job with no estimate lines) or `work_order`.

### Get publish status

`GET /v1/schedule/publish-status`

**Permission:** Read jobs

What the next publish would send: visits in the next 30 days with draft changes.

```json
{
  "data": {
    "status": "draft",
    "unpublished_count": 2,
    "unpublished_work_order_ids": [4120, 4133],
    "unpublished_jobs": [{ "job_id": 70412, "work_order_count": 2 }],
    "last_publication": {
      "id": 812,
      "published_at": "2026-10-30T21:14:05Z",
      "published_by": { "id": 12, "name": "Office Manager" },
      "note": "Week of Nov 2",
      "scope": "all",
      "job_id": null,
      "work_order_count": 14,
      "crew_notified_count": 9
    }
  }
}
```

### Publish the schedule

`POST /v1/schedule/publish`

**Permission:** Write jobs

Sends draft changes to crews, exactly like **Publish** in TruTec. **This reaches people:** crew members get an in-app notification for their visits, and customers get the email or text alert set on each visit (`customer_notify`). Publishing cannot be undone.

| Name | Type | Required | Description |
|---|---|---|---|
| `job_id` | integer | No | Publish only this job's visits. Other drafts stay drafts. |
| `work_order_ids` | array | No | Publish only these visits. |
| `note` | string | No | Shown in the schedule's publish history. |

```bash
curl -X POST "https://trutec.ai/api/v1/schedule/publish" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "note": "Week of Nov 2" }'
```

```json
{
  "data": {
    "published": true,
    "published_count": 14,
    "crew_notified_count": 9,
    "customers_alerted_count": 3,
    "publication": { "id": 813, "published_at": "2026-11-01T18:00:12Z", "published_by": { "id": 12, "name": "Office Manager" }, "note": "Week of Nov 2", "scope": "all", "job_id": null, "work_order_count": 14, "crew_notified_count": 9 }
  }
}
```

With nothing to send, `published` is `false` and the counts are 0.

## Schedule notes

Short notes on a date that are not tied to a job ("Safety meeting 6:30am", "Plant closed"), for the whole workspace, one crew or one foreman.

### List schedule notes

`GET /v1/schedule/notes?start_date=2026-11-02&end_date=2026-11-08`

**Permission:** Read jobs

`start_date` and `end_date` are required (at most 120 days apart). Optional `crew_id`.

```json
{
  "data": [
    {
      "id": 301,
      "date": "2026-11-03",
      "text": "Safety meeting 6:30am",
      "color": "amber",
      "crew": { "id": 18, "name": "Seal Crew 1" },
      "foreman": null,
      "created_at": "2026-10-30T14:02:00Z",
      "updated_at": "2026-10-30T14:02:00Z"
    }
  ],
  "pagination": { "total": 1, "limit": 50, "offset": 0 }
}
```

### Create, update or delete a schedule note

`POST /v1/schedule/notes`

`PATCH /v1/schedule/notes/{id}`

`DELETE /v1/schedule/notes/{id}`

**Permission:** Write jobs (delete also needs Delete records)

| Name | Type | Required | Description |
|---|---|---|---|
| `date` | string | Yes on create | The day. |
| `text` | string | Yes on create | Up to 140 characters. |
| `color` | string | No | `blue`, `navy`, `amber`, `red`, `purple` or `slate`. |
| `crew_id` | integer | No | One crew. |
| `foreman_user_id` | integer | No | One foreman. Not with `crew_id`. |

Create returns `201`, update `200`, delete `204`.

## Crews

### The crew object

| Field | Type | Description |
|---|---|---|
| `id`, `name` | | |
| `is_active` | boolean | Archived crews are `false` and can't be scheduled. |
| `group_name`, `description` | string or null | From your crew catalog. |
| `headcount`, `hours_per_day`, `cost_per_day`, `working_days_per_week` | number | Crew size and cost used by estimating and capacity. |
| `role_slots` | array | The crew's line-up of roles, in order, for example `["Paver", "Screed", "Roller"]`. |
| `members` | array | The people on the crew (see below). |
| `created_at`, `updated_at` | string | UTC timestamps. |

A member has `id` (the membership id), `crew_id`, `user_id` (null for an employee with no TruTec login), `name`, `role` (`lead` or `member`), `work_role` (their slot, for example `Roller`), `work_classification` and `has_app_access`.

### List crews

`GET /v1/crews`

**Permission:** Read jobs

Active crews in the order set on the Crews page. `include_inactive=true` adds archived crews. Also takes `q` (name contains), `updated_after`, `limit` and `offset`.

```json
{
  "data": [
    {
      "id": 18,
      "name": "Seal Crew 1",
      "is_active": true,
      "group_name": "Sealcoating",
      "description": null,
      "headcount": 4,
      "hours_per_day": 10.0,
      "cost_per_day": 2480.0,
      "working_days_per_week": 5,
      "role_slots": ["Lead", "Squeegee", "Sprayer"],
      "members": [
        {
          "id": 77,
          "crew_id": 18,
          "user_id": 344,
          "name": "Crew Lead",
          "role": "lead",
          "work_role": "Lead",
          "work_classification": null,
          "has_app_access": true,
          "created_at": "2026-08-02T15:10:00Z"
        }
      ],
      "created_at": "2026-03-11T19:20:41Z",
      "updated_at": "2026-09-30T13:02:10Z"
    }
  ],
  "pagination": { "total": 1, "limit": 50, "offset": 0 }
}
```

### Get a crew

`GET /v1/crews/{id}`

**Permission:** Read jobs

### Create a crew

`POST /v1/crews`

**Permission:** Write jobs. The key's creator must be a workspace owner.

| Name | Type | Required | Description |
|---|---|---|---|
| `name` | string | Yes | Up to 200 characters. |
| `role_slots` | array | No | Role names, up to 40. |

Returns `201`. A crew with the same name (any case) is not duplicated: you get that crew back with `200`, reactivated if it was archived.

### Update a crew

`PATCH /v1/crews/{id}`

**Permission:** Write jobs (owner)

Send `name` (1 to 120 characters) and/or `role_slots` (the full new line-up; an empty list clears it). Members keep their `work_role` when a slot goes away.

### Archive a crew

`DELETE /v1/crews/{id}`

**Permission:** Write jobs and Delete records (owner)

Archives the crew (history and past visits keep it). Returns `204`. A crew with upcoming scheduled work returns `409`; move that work first.

### Add a crew member

`POST /v1/crews/{id}/members`

**Permission:** Write jobs (owner)

| Name | Type | Required | Description |
|---|---|---|---|
| `user_id` | integer | One of these | A TruTec login in the workspace. |
| `ep_individual_id` or `ep_person_id` | integer | One of these | An employee from your estimating labor catalog. They join without a login. |
| `role` | string | No | `lead` or `member` (default). Logins only. |
| `work_role` | string | No | Their slot on the crew, for example `Roller`. |

Returns `201` with the member, or `200` if they were already on the crew. A crew rule that blocks the move returns `409` with `violations`.

### Update or remove a crew member

`PATCH /v1/crews/{id}/members/{membership_id}` with `{"work_role": "Screed"}` (blank clears it).

`DELETE /v1/crews/{id}/members/{membership_id}` (needs Delete records) takes the person off the crew. Returns `204`.

## People

### List people

`GET /v1/people`

**Permission:** Read jobs

Everyone on any crew roster, one row per person, with the crews they sit on. `person_key` is the identity the schedule uses for people on visits. Filters: `q` (name contains), `crew_id`.

```json
{
  "data": [
    {
      "person_key": "person:51",
      "name": "Crew Lead",
      "user_id": 344,
      "has_app_access": true,
      "license": "cdl_b",
      "skills": ["Screed", "Spanish"],
      "crews": [
        { "id": 18, "name": "Seal Crew 1", "membership_id": 77, "role": "lead", "work_role": "Lead", "is_active": true }
      ]
    }
  ],
  "pagination": { "total": 1, "limit": 50, "offset": 0 }
}
```

To record time for someone, they need a TruTec login (`user_id`). See [Timesheets](timesheets.md). Workspace logins are listed by `GET /v1/users`.
