# Holds & schedule requests

Two ways to get work onto the schedule that the [Schedule & crews](schedule-and-crews.md) endpoints don't cover: **holds**, for dates you want to keep free before a job is priced or won, and **schedule requests**, where someone who can't edit the schedule (or doesn't want to) asks the people who can.

Writes are made as the person who created the API key, with their own TruTec permissions, and need operations turned on for the workspace (`403` `Operations not enabled` otherwise). Holds and approvals need permission to edit the schedule (`403` `view_only` otherwise); anyone in operations can make a request.

## Holds

A hold is a tentative visit: a job name, an address, a line of scope and a date (and optionally a crew and times). With a crew, the hold **locks those crew days** so nothing else is scheduled there. A hold never publishes to crews, is never dispatched, and never tells the customer.

Holds are work orders with `is_hold: true`, so `GET /v1/work-orders?include_holds=true` and `GET /v1/work-orders/{id}` read them too. The hold object is the [work order object](schedule-and-crews.md#the-work-order-object) plus:

| Field | Type | Description |
|---|---|---|
| `locked_days` | array | Crew days this hold blocks. Empty without a crew. |
| `job_is_placeholder` | boolean | The job was made for the hold. Linking the hold to the real job merges it away. |

### List holds

`GET /v1/schedule/holds`

**Permission:** Read jobs

Takes `start_date`, `end_date` (a hold is included when any of its days falls in the range), `crew_id`, `job_id`, `limit` and `offset`. Ordered by date.

### Hold a date

`POST /v1/schedule/holds`

**Permission:** Write jobs

| Field | Type | Required | Description |
|---|---|---|---|
| `scheduled_date` | string | Yes | The day to hold. |
| `job_id` | integer | One of `job_id` or `name` | Hold the date for an existing job. |
| `name` | string | One of `job_id` or `name` | Customer or job name. Makes a lightweight job in the first stage of your sales pipeline, so it stays out of the schedule queue until sales moves it. |
| `scope` | string | No | What the work is. The first line becomes the hold's title. |
| `address` | string | No | Defaults to the job's address. |
| `estimated_days` | number | No | Days on site, 0.5 to 365. Default 1. |
| `start_time`, `end_time` | string | No | Local times like `"07:00"`. A finish before the start runs past midnight. |
| `crew_id` | integer | No | Locks this crew's days. |
| `override_lock` | boolean | No | Owners only: hold a crew day that is already locked. |

```bash
curl -X POST "https://trutec.ai/api/v1/schedule/holds" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Ridgeview HOA", "address": "400 Ridgeview Pkwy, Austin, TX", "scope": "Mill and overlay, phase 1", "scheduled_date": "2026-11-09", "estimated_days": 3, "crew_id": 18 }'
```

Returns `201` with the hold as `data`, `job_created` (a placeholder job was made) and `same_day_work_order_ids` (other work already on that crew day: a heads-up, not a block). A crew day that is already held or locked returns `409` with the `locks` in the way. Workspaces whose jobs come from an outside system must pass `job_id`.

### Change a hold

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

**Permission:** Write jobs

Takes `title`, `scope`, `address`, `scheduled_date`, `estimated_days`, `start_time`, `end_time`, `crew_id` and `override_lock`. Only the fields sent change. Moving it or changing its crew re-checks the locks (`409` when that crew day is taken) and moves its locks along.

### Turn a hold into a visit

`POST /v1/schedule/holds/{id}/convert`

**Permission:** Write jobs

The work was won: the hold becomes a real visit on the same day and crew, and its locks are released. It is a draft until you publish the schedule, like any new visit. Returns the work order.

### Tie a hold to the real job

`POST /v1/schedule/holds/{id}/link` with `{"job_id": 70412}`

**Permission:** Write jobs

The hold moves onto that job and keeps its date, crew, times and locks. When the hold sat on a placeholder job made for it (and nothing else uses it), the placeholder is merged into the real job, its notes, activity and files included, and removed; `merged_placeholder_job` names it.

### Release a hold

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

**Permission:** Write jobs and Delete records

Lets the date go: the hold and its crew-day locks are removed. The job stays. Returns `204`.

## Schedule requests

A request asks the people who can edit the schedule for one of three things:

| `kind` | Asks for | Needs |
|---|---|---|
| `date` | A date for a whole job, or one service line of it | `job_id`, `requested_date`, optionally `estimate_service_id` |
| `move` | Moving a scheduled visit to another date | `work_order_id`, `requested_date` |
| `skip` | One day of a multi-day visit off (not its first day) | `work_order_id`, `skip_date` |

Approving does exactly what the scheduler's own action would. Everyone who can edit the schedule gets an in-app notification of a new request; the requester gets one when it is decided. Nothing reaches customers or crews until the schedule is published.

### The request object

| Field | Type | Description |
|---|---|---|
| `id` | integer | Request id. |
| `kind` | string | `date`, `move` or `skip`. |
| `status` | string | `pending`, `approved` or `declined`. |
| `summary` | string | One plain line, for example "Move Sealcoat: Wed Nov 4 → Thu Nov 5". |
| `job_id`, `job` | integer, object | The job. |
| `requested_date`, `skip_date` | string | The date asked for (for `skip`, both are the day off). |
| `estimate_service_id`, `service_name` | | The service line, for a `date` request about one line. |
| `work_order_id`, `work_order_title` | | The visit, for `move` and `skip`. |
| `note` | string or null | The requester's note. Approving adds it to the visit's dispatch notes. |
| `requested_by`, `decided_by` | object or null | `{id, name}`. |
| `decided_at`, `decision_note`, `created_at` | | |

### List requests

`GET /v1/schedule/date-requests`

**Permission:** Read jobs

Takes `status`, `kind`, `job_id`, `work_order_id`, `limit` and `offset`. Newest first.

### Make a request

`POST /v1/schedule/date-requests`

**Permission:** Write jobs

Takes the fields in the table above plus `note`. Service lines that can be requested are the job's unscheduled lines (`estimate_service_id` from `GET /v1/schedule/queue`); a line already on the calendar is `400` (ask to move its visit instead). A request still waiting for the same thing is `409`.

```bash
curl -X POST "https://trutec.ai/api/v1/schedule/date-requests" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "kind": "move", "work_order_id": 4120, "requested_date": "2026-11-05", "note": "Tenant asked for Thursday" }'
```

Returns `201` with the request.

### Approve or decline

`POST /v1/schedule/date-requests/{id}/approve`

`POST /v1/schedule/date-requests/{id}/decline`

**Permission:** Write jobs

Both take an optional `note` the requester sees. Approve places the work on the date, moves the visit or skips the day, with the same crew-day locks and rules as a drag on the Schedule (`409` when one gets in the way), and returns the request plus the `work_order` it changed. Decline changes nothing on the schedule. A request that was already decided is `409`.
