# Timesheets

A **time entry** is one clock-in and clock-out by one person on one work order (a scheduled visit; see [Schedule & crews](schedule-and-crews.md)). Crews punch in the TruTec crew app; the office adds and corrects time. Approved, unvoided time is what payroll exports and job costing use.

The endpoints follow the Timesheets page in TruTec:

- Every add, correction and void is written to the entry's audit trail with the reason.
- Changing an approved entry clears its approval, so it must be approved again.
- After a change, the job's labor cost is recalculated from the new hours.
- Writes act as the person who created the API key. They need the **Edit timesheets** permission in TruTec to add, correct or void time, and **Approve time** to approve. Otherwise you get `403` naming the permission.

Times you send and the `local_clock_in` / `local_clock_out` you read are 24-hour times in the workspace's time zone. `clock_in` and `clock_out` are UTC with a `Z` suffix.

## The time entry object

| Field | Type | Description |
|---|---|---|
| `id` | integer | |
| `job_id`, `work_order_id` | integer | The job and visit. `work_order` is `{id, title}`. |
| `user_id`, `user` | integer, object | The person, with `{id, name}`. |
| `category` | string | `work`, `travel`, `break` (unpaid) or `shop`. |
| `paid` | boolean | False for breaks. |
| `date` | string | The local day of the clock-in. |
| `clock_in`, `clock_out` | string | UTC. `clock_out` is null while the person is on the clock. |
| `local_clock_in`, `local_clock_out` | string | Local `HH:MM`. |
| `hours` | number or null | Clock time minus any unpaid lunch. |
| `unpaid_lunch_minutes` | integer | |
| `wage_rate`, `wage_rate_kind`, `is_prevailing_wage` | | The hourly cost rate captured at clock-in, and where it came from. |
| `approved_at`, `approved_by` | | Approval. |
| `voided_at` | string or null | Voided entries keep their row and history but count for nothing. |
| `manual_entry` | string or null | Set when a foreman or the office entered the time, with why. |
| `edits` | array | The audit trail (single-entry reads only): `field`, `old_value`, `new_value`, `reason`, `edited_by`, `created_at`. |

## Time entries

### List time entries

`GET /v1/time-entries`

**Permission:** Read jobs

Ordered by clock-in, oldest first. Voided entries are left out unless you ask for them.

| Name | Type | Required | Description |
|---|---|---|---|
| `start_date`, `end_date` | string | No | Local days of the clock-in. |
| `user_id` | integer | No | One person. |
| `job_id`, `work_order_id` | integer | No | One job or visit. |
| `approved` | boolean | No | `true` or `false`. |
| `open` | boolean | No | `true` for people still on the clock. |
| `category` | string | No | `work`, `travel`, `break` or `shop`. |
| `include_voided` | boolean | No | Default `false`. |
| `limit`, `offset` | integer | No | Default 50, maximum 200. |

```bash
curl "https://trutec.ai/api/v1/time-entries?start_date=2026-11-02&end_date=2026-11-08&approved=false" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "data": [
    {
      "id": 602118,
      "job_id": 70412,
      "work_order_id": 4120,
      "user_id": 344,
      "category": "work",
      "paid": true,
      "clock_in": "2026-11-03T13:00:00Z",
      "clock_out": "2026-11-03T22:30:00Z",
      "hours": 9.0,
      "unpaid_lunch_minutes": 30,
      "wage_rate": 28.5,
      "wage_rate_kind": "standard",
      "is_prevailing_wage": false,
      "approved_at": null,
      "voided_at": null,
      "user": { "id": 344, "name": "Crew Lead" },
      "work_order": { "id": 4120, "title": "Sealcoat front lot" },
      "date": "2026-11-03",
      "local_clock_in": "07:00",
      "local_clock_out": "16:30",
      "approved_by": null,
      "manual_entry": null
    }
  ],
  "pagination": { "total": 1, "limit": 50, "offset": 0 }
}
```

Time entries have no `updated_at`. To sync time, pull a date range again, or use the bulk export.

### Get a time entry

`GET /v1/time-entries/{id}`

**Permission:** Read jobs

Includes `edits`, the entry's audit trail.

### Add a time entry

`POST /v1/time-entries`

**Permission:** Write jobs (and Edit timesheets in TruTec)

Adds a shift the crew app did not record: a paper timecard, a dead phone, a missed punch. The wage rate is captured the same way as a real clock-in. The entry lands unapproved, with no GPS, and with a "created" audit row.

| Name | Type | Required | Description |
|---|---|---|---|
| `user_id` | integer | Yes | A TruTec login in the workspace. |
| `work_order_id` | integer | Yes | The visit the time is for. |
| `date` | string | Yes | Local day of the start. |
| `clock_in`, `clock_out` | string | Yes | Local `HH:MM`. A `clock_out` before the start rolls to the next day for shifts up to 14 hours. |
| `category` | string | No | Default `work`. |
| `reason` | string | Yes | Kept in the audit trail, for example "paper timecard". |

```bash
curl -X POST "https://trutec.ai/api/v1/time-entries" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": 344,
    "work_order_id": 4120,
    "date": "2026-11-03",
    "clock_in": "07:00",
    "clock_out": "15:30",
    "reason": "Paper timecard"
  }'
```

Returns `201` with the entry, including `edits`.

**Errors:** `400` for a missing field, a bad time or an end before the start. `404` when the user or work order is not in your workspace.

### Correct a time entry

`PATCH /v1/time-entries/{id}`

**Permission:** Write jobs (and Edit timesheets)

| Name | Type | Required | Description |
|---|---|---|---|
| `date` | string | No | Move the whole shift to another local day. |
| `clock_in`, `clock_out` | string | No | Local `HH:MM`. Sending `clock_out` for an open entry closes it. |
| `category` | string | No | |
| `reason` | string | No | Kept with each change in the audit trail. |

Each changed field gets an audit row and the entry's approval is cleared. Shifts longer than 24 hours are refused. Voided entries must be restored first (`400`).

### Approve or unapprove

`POST /v1/time-entries/{id}/approve`

`POST /v1/time-entries/{id}/unapprove`

**Permission:** Write jobs (and Approve time)

Open shifts (no clock-out) and voided entries cannot be approved (`400`).

### Void or restore

`POST /v1/time-entries/{id}/void` with `{"reason": "Duplicate punch"}`

`POST /v1/time-entries/{id}/restore`

**Permission:** Write jobs (and Edit timesheets)

Voiding never deletes: the entry stays with its history, stops counting toward hours, payroll and job cost, and loses its approval. A reason is required to void.
