Tradesmen Technologies Tradesmen Technologies

API reference Timesheets

Timesheets Service API Endpoints

The production API base URL is https://api.tradesmentechnologies.com/.

Authentication

All endpoints except POST /timesheets/report-view (code-gated, see below) require a Bearer token in the Authorization header: Authorization: Bearer <your_token>

Mutating requests may carry an Idempotency-Key header so a retry never acts twice — see Idempotent Retries in the Accounts API documentation.

Base Path

/prod/timesheets

Authorization

  • Clock-in / Clock-out / Create / Edit / Bulk create / Bulk edit: Any authenticated employee of the company that owns the job.
  • List / Delete: ADMIN employees only.
  • Read own timesheets: any authenticated employee, via GET /timesheets/me.
  • Read other employees' open timesheets: ADMIN, LEAD_TECHNICIAN and CREW_TECHNICIAN employees, via GET /timesheets/open — so a lead can see who is clocked in and clock them out (bulk edit needs the entries' ids). Writes for other employees were never role-gated; this read is what lets a lead clock out an app-access employee who clocked themselves in.
  • Timesheet report page link: ADMIN employees only, via POST /timesheets/report-link. The page's own data endpoint (POST /timesheets/report-view) takes no bearer token — the code in the link is what opens it (see Timesheet report page).

One open timesheet per employee (auto-clock-out)

An employee may have at most one open (clocked-in, not yet clocked-out) timesheet at a time. Starting a new one automatically clocks out the prior open one(s) instead of failing, so a forgotten clock-out never blocks the next clock-in:

  • Clock in (POST /timesheets/clock-in, including the bulk employeeIds form): each clocked-in employee's existing open entries are closed at the new clockedInAt (the supplied timestamp, or now).
  • Create / Bulk create an open entry (no clockedOutAt): the employee's prior open entries are closed at the new entry's clockedInAt. Backfilling an already-closed entry (with clockedOutAt) does not trigger this.
  • Edit / Bulk edit that changes clockedInAt: any other open entry for that timesheet's employee which started at or before the new clockedInAt is closed at that clockedInAt ("the start time of the edited timesheet"). The edited entry itself, and any other entries in the same bulk request, are never auto-closed by this.

Entries that started after the triggering time are left open (they are newer). Each auto-closed entry is stamped with autoClockedOut: true and gets an editHistory entry ("autoClockedOut": true, editedBy set to the caller). The affected entries are returned in an autoClockedOutTimesheets array on the response of every endpoint above (empty array when nothing was closed).

Clock-in types (kinds of time, each paid at a per-employee rate)

A company may define the ways an employee can be clocked in — e.g. Work (normal time) and Travel. The list is company-wide, admin-managed and stored on the company row as clockInTypes (replaced whole by PUT /timesheets/clock-in-types; read by any employee via GET /timesheets/clock-in-types and delivered in the login/session payload as clockInTypes). The list is never empty: every company starts with the default type { "clockInTypeId": "default", "name": "Work", "isDefault": true } — it is what a company that never saved a list gets on every read — and it can be renamed (send it back with its clockInTypeId) but not removed: a PUT with an empty list stores that seeded default again and answers it.

A type:

{
  "clockInTypeId": "uuid",
  "name": "Travel",
  "isDefault": false
}
  • name — required, trimmed, at most 50 characters, unique within the company (case-insensitive).
  • isDefault — exactly one type is the default; when none is flagged the first one is, and flagging more than one is a 400. The default is the company's normal work time: a timesheet entry with no clockInTypeId (every entry created before the feature, or by a client that does not send one) and an entry whose type was later deleted are paid as the default type.
  • clockInTypeId — server-assigned when omitted; send it back unchanged to keep a type's identity across saves (entries reference it). A supplied id is trimmed, must be non-empty and at most 100 characters, and must be unique within the list.
  • At most 20 types.
  • A type carries no pay rate. Each employee has their own cents-per-hour rate per type — the clockInTypeRates map on the employee record ({ "<clockInTypeId>": cents }, set through the employee create / update endpoints in accounts-endpoints.md), beside their plain hourlyRate. An hour of a given type pays the employee: their rate for that type; else their rate for the default type (a type the admin never priced for this employee is paid like normal work, never zero); else their hourlyRate; else nothing. A company with no types pays hourlyRate. This is the rule the timesheet report page applies and that the app mirrors. (Before 2026-10-27 a type carried payRateType / rateMultiplierPercent / fixedHourlyRate; those fields are still accepted on PUT and ignored, and never returned.)

Timesheet entries carry an optional clockInTypeId (bulk create / bulk edit; the single clock-in, POST /timesheets and PATCH /timesheets/{id} endpoints ignore it). It is stored as sent (trimmed; a non-empty string of at most 100 characters, else 400) — not checked against the list, so an offline clock-in still replays after an admin reshuffled the types — and GET /timesheets breaks each employee's totals down by type (byClockInType). Switching the type of an open entry is a plain clock-in with the new type: the prior open entry is auto-clocked-out at that instant (see below).

Get Clock-in Types

  • Method: GET
  • Path: /prod/timesheets/clock-in-types
  • Headers: Authorization: Bearer <token> (any employee)
  • Success Response: 200 OK — { "clockInTypes": [ ...types in display order... ] } (never empty — the seeded default Work type for a company that never saved a list)
  • Error Responses: 401 missing/invalid token; 404 company not found

Update Clock-in Types

Replaces the company's whole list. Removing a type leaves existing entries' clockInTypeId untouched.

  • Method: PUT
  • Path: /prod/timesheets/clock-in-types
  • Headers: Authorization: Bearer <token> (must be ADMIN)
  • Request Body: { "clockInTypes": [ ...types... ] } — each as above; clockInTypeId optional (new type), isDefault optional (the first type when none is flagged). An empty array stores the seeded default Work type (the list is never empty).
  • Success Response: 200 OK — { "clockInTypes": [...] }, the normalized list with every id filled in.
  • Error Responses: 400 invalid list (the message names the offending clockInTypes[i] field); 401; 403 caller is not an ADMIN; 404 company not found

Timesheet report page

A printable web page showing one employee's timesheet breakdown over a date range: every entry, the time per clock-in type (as a pie chart and a table), and the pay per type at the employee's rate for that type. It lives at https://client.tradesmentechnologies.com/timesheet-report/ and has a Save as PDF button like the estimate/invoice viewer (it opens the browser's print dialog; the page title becomes the suggested file name).

Flow: an admin mints a link with POST /timesheets/report-link; the app opens (or shares) that URL; the page reads the code from the URL fragment and exchanges it with POST /timesheets/report-view for the report data, computed server-side. The code is the link's whole secret — anyone holding it sees the employee's hours and pay — so treat the URL like a pay stub. Only a sha256 of the code is stored; the link expires 30 days after it is minted (expiresAt in both responses) and a new link is minted on every call (old links keep working until they expire).

Dates are company-local calendar days (YYYY-MM-DD, inclusive): an entry belongs to the report when its clockedInAt falls on one of those days in the company's time zone (companyTimeZone of the session payload; America/New_York when the company has none). The page displays all times in that zone.

Pay rules (the same ones the app applies, now computed in one place):

  • Each entry is paid in the bucket of its clockInTypeId; an entry with no type, or whose type was deleted, is paid in the default type. A company with no clock-in types gets a single implicit Work bucket at the employee's own hourlyRate.
  • A bucket's hourlyRateCents follows the rule under clock-in types: the employee's clockInTypeRates entry for the type, else their rate for the default type (the bucket then carries usesDefaultRate: true), else their hourlyRate, else null (the bucket is unpriced).
  • A bucket's payCents is round(totalSeconds × hourlyRateCents / 3600), computed from its total seconds (not summed from per-entry roundings), so the bucket rows add up to totals.payCents. Per-entry payCents is the same formula on the entry's own seconds and may differ from the bucket by a cent in total.
  • Open entries (no clockedOutAt) are listed with open: true and excluded from every total, exactly like totalsByEmployee on GET /timesheets. Entries whose timestamps are malformed or negative are listed with seconds: null and not counted.
  • Method: POST
  • Path: /prod/timesheets/report-link
  • Headers: Authorization: Bearer <token> (must be ADMIN)
  • Request Body:
    {
      "employeeId": "employee-uuid",
      "startDate": "2026-10-01",
      "endDate": "2026-10-15"
    }
    
    • employeeId — required; an employee of the caller's company (any role, with or without app access).
    • startDate / endDate — required; YYYY-MM-DD, inclusive, endDate >= startDate, at most 366 days.
  • Success Response: 201 Created
    {
      "url": "https://client.tradesmentechnologies.com/timesheet-report/#code=AbCdEfGhIjKlMnOpQrStUvWx",
      "expiresAt": "2026-11-21T15:04:00.000Z",
      "employeeId": "employee-uuid",
      "startDate": "2026-10-01",
      "endDate": "2026-10-15"
    }
    
  • Error Responses: 400 missing body, missing/invalid employeeId, a date that is not a valid YYYY-MM-DD, endDate before startDate, or a range over 366 days (the message says which); 401; 403 caller is not an ADMIN; 404 employee not found in the caller's company

View Timesheet Report

The page's data endpoint. No bearer token; the body carries the link's code (a body rather than a query string so the code never lands in a URL log).

  • Method: POST
  • Path: /prod/timesheets/report-view
  • Request Body: { "code": "AbCdEfGhIjKlMnOpQrStUvWx" }
  • Success Response: 200 OK (sent with Cache-Control: no-store)
    {
      "company": {
        "name": "Acme Heating & Air",
        "phoneNumber": "+15551234567",
        "email": "office@acmehvac.com",
        "logoUrl": "https://{logos-bucket}.s3.amazonaws.com/company-uuid/logo.png",
        "timeZone": "America/Chicago"
      },
      "employee": {
        "employeeId": "employee-uuid",
        "firstName": "Alice",
        "middleName": null,
        "lastName": "Anders",
        "employeeType": "TECHNICIAN",
        "hourlyRate": 3000,
        "clockInTypeRates": { "work": 3000, "travel": 1500 }
      },
      "startDate": "2026-10-01",
      "endDate": "2026-10-15",
      "generatedAt": "2026-10-22T15:04:00.000Z",
      "expiresAt": "2026-11-21T15:04:00.000Z",
      "clockInTypes": [
        { "clockInTypeId": "work", "name": "Work", "isDefault": true },
        { "clockInTypeId": "travel", "name": "Travel", "isDefault": false }
      ],
      "entries": [
        {
          "timesheetId": "timesheet-uuid",
          "clockedInAt": "2026-10-01T13:02:00.000Z",
          "clockedOutAt": "2026-10-01T21:34:00.000Z",
          "clockInTypeId": null,
          "resolvedClockInTypeId": "work",
          "clockInTypeName": "Work",
          "jobId": "job-uuid",
          "autoClockedOut": false,
          "open": false,
          "seconds": 30720,
          "hourlyRateCents": 3000,
          "payCents": 25600
        }
      ],
      "byClockInType": [
        {
          "clockInTypeId": "work",
          "name": "Work",
          "isDefault": true,
          "hourlyRateCents": 3000,
          "usesDefaultRate": false,
          "totalSeconds": 57720,
          "entryCount": 2,
          "payCents": 48100
        },
        {
          "clockInTypeId": "travel",
          "name": "Travel",
          "isDefault": false,
          "hourlyRateCents": 1500,
          "usesDefaultRate": false,
          "totalSeconds": 2700,
          "entryCount": 1,
          "payCents": 1125
        }
      ],
      "totals": {
        "totalSeconds": 60420,
        "entryCount": 3,
        "openEntries": 1,
        "payCents": 49225,
        "hasUnpricedTime": false
      }
    }
    
    • company.logoUrl, company.phoneNumber, company.email, and the employee's name parts are null when not on record; employee.hourlyRate is null when the employee has no rate and employee.clockInTypeRates is {} when they have no per-type rates.
    • entries are oldest first. clockInTypeId is the id stored on the entry (null when none); resolvedClockInTypeId / clockInTypeName name the bucket it was paid in. hourlyRateCents is the bucket's effective rate; seconds / payCents are null for an open entry.
    • byClockInType lists every defined type in display order (zero-hour types included, so the pay table shows every rate), or the single implicit bucket { "clockInTypeId": null, "name": "Work", "isDefault": true, "usesDefaultRate": false, ... } when the company defines none. usesDefaultRate is true when the employee has no rate of their own for that type and it is priced at their default rate; hourlyRateCents / payCents are null when the rate is unknown.
    • totals.payCents sums the buckets whose rate is known (null when none could be priced); hasUnpricedTime is true when a bucket with time has no known rate, i.e. the total is incomplete.
  • Error Responses: 400 malformed JSON; 404 for everything else that is not a live link — a missing/malformed code, an unknown code, an expired link, or a company/employee that no longer exists (all the same "Report link not found or expired", so nothing can be probed)

Clock-in / clock-out locations (where the device was)

Every clock-in and clock-out made from the app carries the GPS fix of the device that performed it, so an admin can trace a forgotten or disputed clock event. Entries carry two optional, server-stored-as-sent fields:

{
  "clockedInLocation": { "latitude": 40.7128, "longitude": -74.006, "accuracy": 12.5, "capturedAt": "2026-05-12T14:00:03.000Z" },
  "clockedOutLocation": null
}
  • latitude in [-90, 90] and longitude in [-180, 180] are required inside a location; accuracy (meters, non-negative) and capturedAt (ISO instant) are optional and stored as null when absent.
  • Never required: a denied permission, a timed-out fix, a web/desktop client, an admin-entered timesheet or a server auto-clock-out all leave the field absent. A crew clock-in/out carries the lead's device location on every entry it creates or closes.
  • clockedInLocation / clockedOutLocation are accepted by Bulk Create (clockedOutLocation only together with clockedOutAt) and clockedOutLocation by Bulk Edit (only with a non-null clockedOutAt; null removes it; reopening an entry removes it automatically). The single-entry endpoints ignore them. Every read endpoint echoes them as stored.

Idempotency (offline replay)

The create and edit endpoints accept an optional client-supplied key so a request can be safely retried — e.g. an offline client that queued a clock-in/edit and replays it on reconnect, where the original may have succeeded but its response was lost.

  • Create / Bulk create: supply your own timesheetId (a client-generated UUID). The server uses it as the entry's id and writes conditionally. Replaying the same timesheetId returns the existing row unchanged instead of creating a duplicate (201, no new row). If that timesheetId already exists but refers to a different entry (different employeeId/clockedInAt), the request fails with 409. Omit timesheetId to have the server generate one (no idempotency — every call creates a new row, the prior behavior).
  • Edit / Bulk edit: supply an idempotencyKey (a client-generated UUID per logical edit). Replaying an already-applied key returns the current row unchanged without re-applying or adding a duplicate editHistory entry. Omit idempotencyKey for the prior behavior (every call applies and appends to editHistory).

Because the client picks the timesheetId, it also serves as the local handle for correlating a later clock-out/edit before the entry has synced.


Endpoints

Clock In

Creates a new timesheet entry with clockedInAt = now (or the supplied timestamp).

  • Method: POST

  • Path: /prod/timesheets/clock-in

  • Headers: Authorization: Bearer <token>

  • Request Body:

    {
      "jobId": "job-uuid",
      "employeeIds": ["other-employee-uuid"],
      "timestamp": "2026-05-12T14:00:00.000Z"
    }
    

    jobId is optional. When provided, it must reference an existing job belonging to the caller's company. When omitted, the timesheet is created without an associated job and the jobId field is absent from the stored entry (it will not appear in responses for that timesheet).

    employeeIds is optional. The caller is always clocked in. When employeeIds is provided, each listed employee is also clocked in at the same clockedInAt time (and against the same jobId, if any). Any authenticated employee may use this — no admin role is required. Ids that do not match an employee in the caller's company are skipped (the rest are still clocked in) and returned in skippedEmployeeIds. The caller's own id and duplicate ids are ignored so no employee is clocked in twice.

    timestamp is optional; a valid ISO timestamp recording when the clock-in actually happened. This supports offline use in areas with poor reception: the app captures the real clock-in moment locally and replays the request when connectivity returns. When provided it becomes the entry's clockedInAt (and the time at which any prior open entries are auto-clocked-out); createdAt/updatedAt still reflect the server write time. When omitted, clockedInAt defaults to the server's current time (the prior behavior).

  • Success Response: 201 Created

    {
      "message": "Clocked in successfully",
      "timesheet": {
        "companyId": "company-uuid",
        "timesheetId": "timesheet-uuid",
        "jobId": "job-uuid",
        "employeeId": "employee-uuid",
        "clockedInAt": "2026-05-12T14:00:00.000Z",
        "createdAt": "2026-05-12T14:00:00.000Z",
        "updatedAt": "2026-05-12T14:00:00.000Z"
      },
      "timesheets": [
        {
          "companyId": "company-uuid",
          "timesheetId": "timesheet-uuid",
          "jobId": "job-uuid",
          "employeeId": "employee-uuid",
          "clockedInAt": "2026-05-12T14:00:00.000Z",
          "createdAt": "2026-05-12T14:00:00.000Z",
          "updatedAt": "2026-05-12T14:00:00.000Z"
        }
      ],
      "skippedEmployeeIds": [],
      "autoClockedOutTimesheets": []
    }
    

    timesheet is the caller's entry (always present, unchanged for existing clients). timesheets lists every entry created by this request — the caller first, followed by one per successfully clocked-in employee from employeeIds. skippedEmployeeIds lists any employeeIds that did not match an employee in the caller's company. autoClockedOutTimesheets lists any previously-open entries that were automatically clocked out at the new clock-in time (see One open timesheet per employee); it is an empty array when none were open.

Clock Out

Sets clockedOutAt = now (or the supplied timestamp) on an existing open timesheet.

  • Method: POST

  • Path: /prod/timesheets/clock-out

  • Headers: Authorization: Bearer <token>

  • Request Body:

    {
      "timesheetId": "timesheet-uuid",
      "employeeIds": ["other-employee-uuid"],
      "timestamp": "2026-05-12T18:30:00.000Z"
    }
    

    At least one of timesheetId or employeeIds is required.

    timesheetId closes that specific open entry. When it references an entry that is not found it returns 404, and when the entry is already clocked out it returns 409 (unchanged single-entry behavior).

    employeeIds is optional. For each listed employee, all of their currently open timesheets are clocked out at the same clockedOutAt time. Any authenticated employee may use this — no admin role is required. Employees with no open timesheet in the caller's company are skipped and returned in skippedEmployeeIds. Duplicate ids are ignored. Unlike the timesheetId path, an employee with nothing to close does not fail the request.

    timestamp is optional; a valid ISO timestamp recording when the clock-out actually happened. This supports offline use in areas with poor reception: the app captures the real clock-out moment locally and replays the request when connectivity returns. When provided it becomes the entry's clockedOutAt; updatedAt still reflects the server write time. A backdated timestamp must not be before an entry's clockedInAt (that would create a negative-duration shift), and the two paths handle a conflict differently:

    • timesheetId (strict): if the named entry's clockedInAt is after the timestamp, the request is rejected with 400 and nothing is closed.
    • employeeIds (lenient): any of an employee's open entries that the timestamp would predate are left open and the employee is reported in conflictingEmployeeIds; the employee's other open entries, and all other employees, are still closed and the request returns 200.

    When omitted, clockedOutAt defaults to the server's current time (the prior behavior).

  • Success Response: 200 OK

    {
      "message": "Clocked out successfully",
      "timesheet": {
        "companyId": "company-uuid",
        "timesheetId": "timesheet-uuid",
        "jobId": "job-uuid",
        "employeeId": "employee-uuid",
        "clockedInAt": "2026-05-12T14:00:00.000Z",
        "clockedOutAt": "2026-05-12T18:30:00.000Z",
        "createdAt": "2026-05-12T14:00:00.000Z",
        "updatedAt": "2026-05-12T18:30:00.000Z"
      },
      "timesheets": [
        {
          "companyId": "company-uuid",
          "timesheetId": "timesheet-uuid",
          "jobId": "job-uuid",
          "employeeId": "employee-uuid",
          "clockedInAt": "2026-05-12T14:00:00.000Z",
          "clockedOutAt": "2026-05-12T18:30:00.000Z",
          "createdAt": "2026-05-12T14:00:00.000Z",
          "updatedAt": "2026-05-12T18:30:00.000Z"
        }
      ],
      "skippedEmployeeIds": [],
      "conflictingEmployeeIds": []
    }
    

    timesheet is the entry closed via timesheetId (or, when only employeeIds was sent, the first closed entry) — present for backward compatibility, and absent when the request closed nothing (every listed employee was skipped or conflicting). timesheets lists every entry closed by this request. skippedEmployeeIds lists any employeeIds that had no open timesheet to close. conflictingEmployeeIds lists any employeeIds that had at least one open timesheet which could not be closed because the supplied timestamp is before that entry's clockedInAt (see the timestamp field above); those entries are left open while the rest of the request still completes. It is an empty array when no timestamp is supplied or none conflict.

Get My Timesheets

Returns timesheets belonging to the calling employee. Available to any authenticated employee — no admin role required. The admin GET /timesheets endpoint is unchanged.

  • Method: GET
  • Path: /prod/timesheets/me
  • Headers: Authorization: Bearer <token>
  • Query Parameters:
    • status — one of open, closed, all (default: open)
    • jobId — return only entries for this job
  • Success Response: 200 OK
    {
      "timesheets": [
        {
          "companyId": "company-uuid",
          "timesheetId": "timesheet-uuid",
          "jobId": "job-uuid",
          "employeeId": "employee-uuid",
          "clockedInAt": "2026-05-12T14:00:00.000Z",
          "clockedOutAt": "2026-05-12T18:30:00.000Z",
          "createdAt": "2026-05-12T14:00:00.000Z",
          "updatedAt": "2026-05-12T18:30:00.000Z"
        }
      ]
    }
    
    Returns 200 OK with an empty timesheets array when there are no matches. Results are always scoped to the caller's own employeeId and companyId; cross-employee reads are not supported (the employeeId query parameter is ignored).

Get Open Timesheets of Other Employees

Returns the open (not yet clocked-out) timesheets of the listed employees, so a lead technician or admin can see who is clocked in and close their entries with Bulk Edit Timesheets. GET /timesheets/me stays caller-scoped; this is the cross-employee read, and unlike the admin-only GET /timesheets it is open to lead technicians.

  • Method: GET
  • Path: /prod/timesheets/open
  • Headers: Authorization: Bearer <token> (must be ADMIN, LEAD_TECHNICIAN or CREW_TECHNICIAN)
  • Query Parameters:
    • employeeIds (required) — comma-separated employee ids, at most 100 (duplicates and blanks are ignored). Ids outside the caller's company simply return no rows.
  • Success Response: 200 OK
    {
      "timesheets": [
        {
          "companyId": "company-uuid",
          "timesheetId": "timesheet-uuid",
          "jobId": "job-uuid",
          "employeeId": "employee-uuid",
          "clockedInAt": "2026-05-12T14:00:00.000Z",
          "createdAt": "2026-05-12T14:00:00.000Z",
          "updatedAt": "2026-05-12T14:00:00.000Z"
        }
      ]
    }
    
    Entries are returned in employee order (as listed in employeeIds), oldest clock-in first within an employee. 200 OK with an empty timesheets array when nobody listed is clocked in. The caller's own open entries are included only when their id is listed.
  • Error Responses:
    • 400 — employeeIds missing/empty, or more than 100 ids
    • 401 — missing or invalid token
    • 403 — caller is not an ADMIN, LEAD_TECHNICIAN or CREW_TECHNICIAN employee

List Timesheets

Returns all timesheets for the caller's company, optionally filtered.

  • Method: GET
  • Path: /prod/timesheets
  • Headers: Authorization: Bearer <token> (must be ADMIN)
  • Query Parameters:
    • employeeId — return only entries for this employee
    • jobId — return only entries for this job
    • status — one of open, closed, all (default: all)
    • startDate — ISO timestamp; return only entries with clockedInAt >= startDate
    • endDate — ISO timestamp; return only entries with clockedInAt <= endDate
  • Success Response: 200 OK
    {
      "timesheets": [
        {
          "companyId": "company-uuid",
          "timesheetId": "timesheet-uuid",
          "jobId": "job-uuid",
          "employeeId": "employee-uuid",
          "clockedInAt": "2026-05-12T14:00:00.000Z",
          "clockedOutAt": "2026-05-12T18:30:00.000Z",
          "createdAt": "2026-05-12T14:00:00.000Z",
          "updatedAt": "2026-05-12T18:30:00.000Z"
        }
      ],
      "totalsByEmployee": [
        {
          "employeeId": "employee-uuid",
          "firstName": "Alice",
          "lastName": "Anders",
          "totalSeconds": 16200
        }
      ]
    }
    
    totalsByEmployee sums the duration (clockedOutAt - clockedInAt, in seconds) of every returned timesheet, grouped by employeeId. Open entries (no clockedOutAt) are excluded from the sums, and employees with only open entries are omitted from the array. firstName/lastName may be omitted if the employee record is missing. Each row also carries byClockInType: the same closed-entry seconds split by the entries' clockInTypeId ([{ "clockInTypeId": "uuid" | null, "totalSeconds": 3600 }, ...], null for entries without a type), so the app can price each clock-in type at its own rate; the parts sum to totalSeconds.

Create Timesheet

Creates a complete timesheet entry on behalf of another employee — e.g. backfilling a shift someone forgot to clock. Unlike clock-in (which creates a caller-scoped, open entry with clockedInAt = now), this lets the caller set the employee, job, and exact timestamps. Any authenticated employee of the company may call it — no admin role is required. When the new entry is open (no clockedOutAt), the employee's prior open entries are auto-clocked-out at the new entry's clockedInAt (see One open timesheet per employee); backfilling an already-closed entry does not.

  • Method: POST
  • Path: /prod/timesheets
  • Headers: Authorization: Bearer <token>
  • Request Body:
    {
      "employeeId": "employee-uuid",
      "jobId": "job-uuid",
      "clockedInAt": "2026-05-12T14:00:00.000Z",
      "clockedOutAt": "2026-05-12T18:30:00.000Z",
      "timesheetId": "client-generated-uuid"
    }
    
    • employeeId — required; must reference an employee in the caller's company.
    • clockedInAt — required; valid ISO timestamp.
    • clockedOutAt — optional; valid ISO timestamp. When omitted, the entry is created open (clocked in, not yet out). When present, it must be greater than or equal to clockedInAt.
    • jobId — optional; when provided must reference an existing job in the caller's company. When omitted, the entry is stored without an associated job and the jobId field is absent from responses.
    • timesheetId — optional; a client-generated UUID used as the entry's id for idempotent retries (see Idempotency). When omitted, the server generates one.
    • clockInTypeId — Bulk Create only (this single-entry endpoint ignores it): optional non-empty string naming one of the company's clock-in types; stored as sent and echoed on the row. Absent/null = no type (paid at the default type).
    • clockedInLocation / clockedOutLocation — Bulk Create only: optional device locations; clockedOutLocation needs clockedOutAt.
  • Success Response: 201 Created
    {
      "message": "Timesheet created successfully",
      "timesheet": {
        "companyId": "company-uuid",
        "timesheetId": "timesheet-uuid",
        "jobId": "job-uuid",
        "employeeId": "employee-uuid",
        "clockedInAt": "2026-05-12T14:00:00.000Z",
        "clockedOutAt": "2026-05-12T18:30:00.000Z",
        "createdAt": "2026-05-12T19:00:00.000Z",
        "updatedAt": "2026-05-12T19:00:00.000Z"
      },
      "autoClockedOutTimesheets": []
    }
    
    autoClockedOutTimesheets lists any of the employee's previously-open entries that were auto-clocked-out at the new entry's clockedInAt (empty unless the new entry is open and the employee had other open entries). An idempotent replay (a reused timesheetId matching the stored row) returns message and timesheet only — no autoClockedOutTimesheets field.

Bulk Create Timesheets

Creates many timesheet entries in one request — the batch form of Create Timesheet, e.g. backfilling a full crew's shifts at once. Validation is all-or-nothing: if any entry is invalid the whole request is rejected and nothing is written, so the caller can fix the offending entry and resubmit. Any authenticated employee of the company may call it.

  • Method: POST
  • Path: /prod/timesheets/bulk
  • Headers: Authorization: Bearer <token>
  • Request Body:
    {
      "timesheets": [
        {
          "employeeId": "employee-uuid",
          "jobId": "job-uuid",
          "clockedInAt": "2026-05-12T14:00:00.000Z",
          "clockedOutAt": "2026-05-12T18:30:00.000Z"
        },
        {
          "employeeId": "other-employee-uuid",
          "clockedInAt": "2026-05-12T09:00:00.000Z"
        }
      ]
    }
    
    • timesheets — required; a non-empty array of at most 100 entries.
    • Each entry takes the same fields as Create Timesheet: employeeId (required), clockedInAt (required, valid ISO), clockedOutAt (optional, valid ISO, >= clockedInAt), jobId (optional; must reference a job in the caller's company), and timesheetId (optional client-generated UUID for idempotent retries — see Idempotency; must be unique within the request) — plus the bulk-only clockInTypeId (optional non-empty string, stored trimmed; see clock-in types) and clockedInLocation / clockedOutLocation (optional; clockedOutLocation requires clockedOutAt; see locations).
  • Success Response: 201 Created
    {
      "message": "Timesheets created successfully",
      "timesheets": [
        {
          "companyId": "company-uuid",
          "timesheetId": "timesheet-uuid",
          "jobId": "job-uuid",
          "employeeId": "employee-uuid",
          "clockedInAt": "2026-05-12T14:00:00.000Z",
          "clockedOutAt": "2026-05-12T18:30:00.000Z",
          "createdAt": "2026-05-12T19:00:00.000Z",
          "updatedAt": "2026-05-12T19:00:00.000Z"
        }
      ],
      "autoClockedOutTimesheets": []
    }
    
    timesheets lists every entry, in the same order as the request — newly created rows plus any that already existed from a prior attempt (idempotent replays via a reused timesheetId). When an entry fails validation, the error response includes a zero-based index identifying which entry failed (e.g. "message": "timesheets[1]: Employee not found", "index": 1). A reused timesheetId that points at a different entry fails the whole request with 409. autoClockedOutTimesheets lists any prior open entries auto-clocked-out for the employees of newly-created open entries (deduplicated; entries in this request are never auto-closed).

Edit Timesheet

Updates clockedInAt and/or clockedOutAt on an existing entry. Previous values are appended to editHistory.

  • Method: PATCH
  • Path: /prod/timesheets/{timesheetId}
  • Headers: Authorization: Bearer <token>
  • Path Parameters:
    • timesheetId — the timesheet to update
  • Request Body: At least one of clockedInAt / clockedOutAt:
    {
      "clockedInAt": "2026-05-12T14:00:00.000Z",
      "clockedOutAt": "2026-05-12T18:30:00.000Z",
      "idempotencyKey": "client-generated-uuid"
    }
    
    Passing "clockedOutAt": null reopens a closed entry. idempotencyKey is optional — a client-generated UUID that makes the edit safe to retry (see Idempotency); replaying an already-applied key returns the row unchanged and adds no new editHistory entry.
  • Success Response: 200 OK
    {
      "message": "Timesheet updated successfully",
      "timesheet": {
        "companyId": "company-uuid",
        "timesheetId": "timesheet-uuid",
        "jobId": "job-uuid",
        "employeeId": "employee-uuid",
        "clockedInAt": "2026-05-12T14:00:00.000Z",
        "clockedOutAt": "2026-05-12T18:30:00.000Z",
        "createdAt": "2026-05-12T14:00:00.000Z",
        "updatedAt": "2026-05-13T09:00:00.000Z",
        "editHistory": [
          {
            "editedAt": "2026-05-13T09:00:00.000Z",
            "editedBy": "employee-uuid",
            "previousClockedInAt": "2026-05-12T14:05:00.000Z",
            "previousClockedOutAt": "2026-05-12T18:25:00.000Z"
          }
        ]
      },
      "autoClockedOutTimesheets": []
    }
    
    When the edit changes clockedInAt, autoClockedOutTimesheets lists any other open entry for the same employee that started at or before the new clockedInAt and was auto-clocked-out at it (empty otherwise). See One open timesheet per employee. An idempotent replay (an already-applied idempotencyKey) returns message and timesheet only — no autoClockedOutTimesheets field.

Bulk Edit Timesheets

Updates clockedInAt and/or clockedOutAt on many existing entries in one request — the batch form of Edit Timesheet. Each entry's previous values are appended to that timesheet's editHistory. Validation is all-or-nothing: if any entry is invalid the whole request is rejected and nothing is written. Any authenticated employee of the company may call it.

  • Method: PATCH
  • Path: /prod/timesheets/bulk
  • Headers: Authorization: Bearer <token>
  • Request Body:
    {
      "updates": [
        {
          "timesheetId": "timesheet-uuid-1",
          "clockedOutAt": "2026-05-12T18:30:00.000Z"
        },
        {
          "timesheetId": "timesheet-uuid-2",
          "clockedInAt": "2026-05-12T09:00:00.000Z",
          "clockedOutAt": null
        }
      ]
    }
    
    • updates — required; a non-empty array of at most 100 entries. timesheetIds must be unique within the request.
    • Each entry may also carry clockInTypeId (a non-empty string sets the entry's clock-in type, null removes it) and then needs no timestamp change; the editHistory entry records previousClockInTypeId whenever the field was sent.
    • clockedOutLocation — optional with a non-null clockedOutAt (see locations); null removes a stored one. Sending it without clockedOutAt is a 400.
    • Each entry requires timesheetId and at least one of clockedInAt / clockedOutAt (valid ISO timestamps) / clockInTypeId. Passing "clockedOutAt": null reopens that entry. The effective clockedOutAt must be >= clockedInAt. Each entry may also include an optional idempotencyKey (client-generated UUID) for idempotent retries — see Idempotency.
  • Success Response: 200 OK
    {
      "message": "Timesheets updated successfully",
      "timesheets": [
        {
          "companyId": "company-uuid",
          "timesheetId": "timesheet-uuid-1",
          "jobId": "job-uuid",
          "employeeId": "employee-uuid",
          "clockedInAt": "2026-05-12T14:00:00.000Z",
          "clockedOutAt": "2026-05-12T18:30:00.000Z",
          "createdAt": "2026-05-12T14:00:00.000Z",
          "updatedAt": "2026-05-13T09:00:00.000Z",
          "editHistory": [
            {
              "editedAt": "2026-05-13T09:00:00.000Z",
              "editedBy": "employee-uuid",
              "previousClockedInAt": "2026-05-12T14:05:00.000Z",
              "previousClockedOutAt": "2026-05-12T18:25:00.000Z"
            }
          ]
        }
      ],
      "autoClockedOutTimesheets": []
    }
    
    timesheets lists every entry, in the same order as the request — newly updated rows plus any whose idempotencyKey had already been applied (returned unchanged). When an entry fails validation, the error response includes a zero-based index identifying which entry failed (e.g. "message": "updates[1]: Timesheet not found", "index": 1). autoClockedOutTimesheets lists any prior open entries auto-clocked-out for entries whose clockedInAt changed (deduplicated; entries in this request are never auto-closed).

Delete Timesheet

Permanently removes a timesheet entry. This is a hard delete — the underlying record is removed entirely and cannot be recovered.

  • Method: DELETE
  • Path: /prod/timesheets/{timesheetId}
  • Headers: Authorization: Bearer <token> (must be ADMIN)
  • Path Parameters:
    • timesheetId — the timesheet to delete
  • Request Body: None
  • Success Response: 204 No Content with an empty body.
  • Errors:
    • 401 — missing, invalid, or expired auth token
    • 403 — caller is not an ADMIN employee
    • 404 — timesheet not found (also returned for timesheetIds belonging to other companies, so the endpoint does not leak the existence of other companies' ids)

Validation Errors

Status Condition
400 Missing request body
400 jobId (clock-in, create, bulk create) is provided but is not a string
400 employeeIds (clock-in, clock-out) is provided but is not an array of strings
400 timesheets (bulk create) or updates (bulk edit) is not a non-empty array
400 timesheets (bulk create) or updates (bulk edit) has more than 100 entries
400 Duplicate timesheetId within a bulk create or bulk edit request
400 timesheetId (create, bulk create) is provided but is not a non-empty string
400 idempotencyKey (edit, bulk edit) is provided but is not a non-empty string
400 Missing employeeId (create, bulk create)
400 Missing clockedInAt (create, bulk create)
400 Neither timesheetId nor employeeIds provided (clock-out)
400 timestamp (clock-in, clock-out) is provided but is not a valid ISO timestamp
400 timestamp (clock-out, timesheetId path only) is before the named entry's clockedInAt — the lenient employeeIds path skips and reports such entries in conflictingEmployeeIds instead
400 Missing timesheetId (edit, bulk edit)
400 clockedInAt or clockedOutAt is not a valid ISO timestamp
400 clockedOutAt is before clockedInAt
400 status query parameter is not one of open, closed, all
400 Edit request has no editable fields (edit, bulk edit)
400 clockInTypeId (bulk create, bulk edit) is provided but is not a non-empty string of at most 100 characters
400 clockedInLocation / clockedOutLocation (bulk create, bulk edit) is malformed, or clockedOutLocation is sent without a clockedOutAt
401 Missing, invalid, or expired auth token
403 Caller is not an ADMIN employee (list, delete, update clock-in types, create report link)
403 Caller is not an ADMIN, LEAD_TECHNICIAN or CREW_TECHNICIAN employee (open timesheets of other employees)
404 employeeId does not match an employee in the caller's company (create, bulk create, create report link)
404 Company not found (get / update clock-in types)
404 jobId was provided but does not match an existing job in the caller's company (clock-in, create, bulk create)
404 Timesheet not found (clock-out, edit, bulk edit, delete)
409 Timesheet is already clocked out (clock-out)
409 A reused client timesheetId points at a different existing entry (create, bulk create)

For the bulk endpoints, 400/404 validation responses also include a zero-based index field identifying the offending entry, and the message is prefixed with timesheets[<index>]: (bulk create) or updates[<index>]: (bulk edit).