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:
ADMINemployees only. - Read own timesheets: any authenticated employee, via
GET /timesheets/me. - Read other employees' open timesheets:
ADMIN,LEAD_TECHNICIANandCREW_TECHNICIANemployees, viaGET /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:
ADMINemployees only, viaPOST /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 bulkemployeeIdsform): each clocked-in employee's existing open entries are closed at the newclockedInAt(the suppliedtimestamp, ornow). - Create / Bulk create an open entry (no
clockedOutAt): the employee's prior open entries are closed at the new entry'sclockedInAt. Backfilling an already-closed entry (withclockedOutAt) 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 newclockedInAtis closed at thatclockedInAt("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 a400. The default is the company's normal work time: a timesheet entry with noclockInTypeId(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
clockInTypeRatesmap on the employee record ({ "<clockInTypeId>": cents }, set through the employee create / update endpoints inaccounts-endpoints.md), beside their plainhourlyRate. 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 theirhourlyRate; else nothing. A company with no types payshourlyRate. This is the rule the timesheet report page applies and that the app mirrors. (Before 2026-10-27 a type carriedpayRateType/rateMultiplierPercent/fixedHourlyRate; those fields are still accepted onPUTand 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 defaultWorktype for a company that never saved a list) - Error Responses:
401missing/invalid token;404company 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 beADMIN) - Request Body:
{ "clockInTypes": [ ...types... ] }— each as above;clockInTypeIdoptional (new type),isDefaultoptional (the first type when none is flagged). An empty array stores the seeded defaultWorktype (the list is never empty). - Success Response:
200 OK—{ "clockInTypes": [...] }, the normalized list with every id filled in. - Error Responses:
400invalid list (the message names the offendingclockInTypes[i]field);401;403caller is not anADMIN;404company 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 ownhourlyRate. - A bucket's
hourlyRateCentsfollows the rule under clock-in types: the employee'sclockInTypeRatesentry for the type, else their rate for the default type (the bucket then carriesusesDefaultRate: true), else theirhourlyRate, elsenull(the bucket is unpriced). - A bucket's
payCentsisround(totalSeconds × hourlyRateCents / 3600), computed from its total seconds (not summed from per-entry roundings), so the bucket rows add up tototals.payCents. Per-entrypayCentsis 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 withopen: trueand excluded from every total, exactly liketotalsByEmployeeonGET /timesheets. Entries whose timestamps are malformed or negative are listed withseconds: nulland not counted.
Create Timesheet Report Link
- Method: POST
- Path:
/prod/timesheets/report-link - Headers:
Authorization: Bearer <token>(must beADMIN) - 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:
400missing body, missing/invalidemployeeId, a date that is not a validYYYY-MM-DD,endDatebeforestartDate, or a range over 366 days (the message says which);401;403caller is not anADMIN;404employee 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 withCache-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 arenullwhen not on record;employee.hourlyRateisnullwhen the employee has no rate andemployee.clockInTypeRatesis{}when they have no per-type rates.entriesare oldest first.clockInTypeIdis the id stored on the entry (nullwhen none);resolvedClockInTypeId/clockInTypeNamename the bucket it was paid in.hourlyRateCentsis the bucket's effective rate;seconds/payCentsarenullfor an open entry.byClockInTypelists 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.usesDefaultRateistruewhen the employee has no rate of their own for that type and it is priced at their default rate;hourlyRateCents/payCentsarenullwhen the rate is unknown.totals.payCentssums the buckets whose rate is known (nullwhen none could be priced);hasUnpricedTimeistruewhen a bucket with time has no known rate, i.e. the total is incomplete.
- Error Responses:
400malformed JSON;404for 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
}
latitudein [-90, 90] andlongitudein [-180, 180] are required inside a location;accuracy(meters, non-negative) andcapturedAt(ISO instant) are optional and stored asnullwhen 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/clockedOutLocationare accepted by Bulk Create (clockedOutLocationonly together withclockedOutAt) andclockedOutLocationby Bulk Edit (only with a non-nullclockedOutAt;nullremoves 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 sametimesheetIdreturns the existing row unchanged instead of creating a duplicate (201, no new row). If thattimesheetIdalready exists but refers to a different entry (differentemployeeId/clockedInAt), the request fails with409. OmittimesheetIdto 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 duplicateeditHistoryentry. OmitidempotencyKeyfor the prior behavior (every call applies and appends toeditHistory).
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-inHeaders:
Authorization: Bearer <token>Request Body:
{ "jobId": "job-uuid", "employeeIds": ["other-employee-uuid"], "timestamp": "2026-05-12T14:00:00.000Z" }jobIdis 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 thejobIdfield is absent from the stored entry (it will not appear in responses for that timesheet).employeeIdsis optional. The caller is always clocked in. WhenemployeeIdsis provided, each listed employee is also clocked in at the sameclockedInAttime (and against the samejobId, 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 inskippedEmployeeIds. The caller's own id and duplicate ids are ignored so no employee is clocked in twice.timestampis 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'sclockedInAt(and the time at which any prior open entries are auto-clocked-out);createdAt/updatedAtstill reflect the server write time. When omitted,clockedInAtdefaults 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": [] }timesheetis the caller's entry (always present, unchanged for existing clients).timesheetslists every entry created by this request — the caller first, followed by one per successfully clocked-in employee fromemployeeIds.skippedEmployeeIdslists anyemployeeIdsthat did not match an employee in the caller's company.autoClockedOutTimesheetslists 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-outHeaders:
Authorization: Bearer <token>Request Body:
{ "timesheetId": "timesheet-uuid", "employeeIds": ["other-employee-uuid"], "timestamp": "2026-05-12T18:30:00.000Z" }At least one of
timesheetIdoremployeeIdsis required.timesheetIdcloses that specific open entry. When it references an entry that is not found it returns404, and when the entry is already clocked out it returns409(unchanged single-entry behavior).employeeIdsis optional. For each listed employee, all of their currently open timesheets are clocked out at the sameclockedOutAttime. 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 inskippedEmployeeIds. Duplicate ids are ignored. Unlike thetimesheetIdpath, an employee with nothing to close does not fail the request.timestampis 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'sclockedOutAt;updatedAtstill reflects the server write time. A backdatedtimestampmust not be before an entry'sclockedInAt(that would create a negative-duration shift), and the two paths handle a conflict differently:timesheetId(strict): if the named entry'sclockedInAtis after thetimestamp, the request is rejected with400and nothing is closed.employeeIds(lenient): any of an employee's open entries that thetimestampwould predate are left open and the employee is reported inconflictingEmployeeIds; the employee's other open entries, and all other employees, are still closed and the request returns200.
When omitted,
clockedOutAtdefaults 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": [] }timesheetis the entry closed viatimesheetId(or, when onlyemployeeIdswas sent, the first closed entry) — present for backward compatibility, and absent when the request closed nothing (every listed employee was skipped or conflicting).timesheetslists every entry closed by this request.skippedEmployeeIdslists anyemployeeIdsthat had no open timesheet to close.conflictingEmployeeIdslists anyemployeeIdsthat had at least one open timesheet which could not be closed because the suppliedtimestampis before that entry'sclockedInAt(see thetimestampfield above); those entries are left open while the rest of the request still completes. It is an empty array when notimestampis 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 ofopen,closed,all(default:open)jobId— return only entries for this job
- Success Response:
200 OK
Returns{ "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" } ] }200 OKwith an emptytimesheetsarray when there are no matches. Results are always scoped to the caller's ownemployeeIdandcompanyId; cross-employee reads are not supported (theemployeeIdquery 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 beADMIN,LEAD_TECHNICIANorCREW_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
Entries are returned in employee order (as listed in{ "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" } ] }employeeIds), oldest clock-in first within an employee.200 OKwith an emptytimesheetsarray when nobody listed is clocked in. The caller's own open entries are included only when their id is listed. - Error Responses:
400—employeeIdsmissing/empty, or more than 100 ids401— missing or invalid token403— caller is not anADMIN,LEAD_TECHNICIANorCREW_TECHNICIANemployee
List Timesheets
Returns all timesheets for the caller's company, optionally filtered.
- Method: GET
- Path:
/prod/timesheets - Headers:
Authorization: Bearer <token>(must beADMIN) - Query Parameters:
employeeId— return only entries for this employeejobId— return only entries for this jobstatus— one ofopen,closed,all(default:all)startDate— ISO timestamp; return only entries withclockedInAt >= startDateendDate— ISO timestamp; return only entries withclockedInAt <= 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 } ] }totalsByEmployeesums the duration (clockedOutAt - clockedInAt, in seconds) of every returned timesheet, grouped byemployeeId. Open entries (noclockedOutAt) are excluded from the sums, and employees with only open entries are omitted from the array.firstName/lastNamemay be omitted if the employee record is missing. Each row also carriesbyClockInType: the same closed-entry seconds split by the entries'clockInTypeId([{ "clockInTypeId": "uuid" | null, "totalSeconds": 3600 }, ...],nullfor entries without a type), so the app can price each clock-in type at its own rate; the parts sum tototalSeconds.
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 toclockedInAt.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 thejobIdfield 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;clockedOutLocationneedsclockedOutAt.
- 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": [] }autoClockedOutTimesheetslists any of the employee's previously-open entries that were auto-clocked-out at the new entry'sclockedInAt(empty unless the new entry is open and the employee had other open entries). An idempotent replay (a reusedtimesheetIdmatching the stored row) returnsmessageandtimesheetonly — noautoClockedOutTimesheetsfield.
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), andtimesheetId(optional client-generated UUID for idempotent retries — see Idempotency; must be unique within the request) — plus the bulk-onlyclockInTypeId(optional non-empty string, stored trimmed; see clock-in types) andclockedInLocation/clockedOutLocation(optional;clockedOutLocationrequiresclockedOutAt; 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": [] }timesheetslists 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 reusedtimesheetId). When an entry fails validation, the error response includes a zero-basedindexidentifying which entry failed (e.g."message": "timesheets[1]: Employee not found", "index": 1). A reusedtimesheetIdthat points at a different entry fails the whole request with409.autoClockedOutTimesheetslists 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:
Passing{ "clockedInAt": "2026-05-12T14:00:00.000Z", "clockedOutAt": "2026-05-12T18:30:00.000Z", "idempotencyKey": "client-generated-uuid" }"clockedOutAt": nullreopens a closed entry.idempotencyKeyis 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 neweditHistoryentry. - Success Response:
200 OK
When the edit changes{ "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": [] }clockedInAt,autoClockedOutTimesheetslists any other open entry for the same employee that started at or before the newclockedInAtand was auto-clocked-out at it (empty otherwise). See One open timesheet per employee. An idempotent replay (an already-appliedidempotencyKey) returnsmessageandtimesheetonly — noautoClockedOutTimesheetsfield.
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,nullremoves it) and then needs no timestamp change; theeditHistoryentry recordspreviousClockInTypeIdwhenever the field was sent. clockedOutLocation— optional with a non-nullclockedOutAt(see locations);nullremoves a stored one. Sending it withoutclockedOutAtis a400.- Each entry requires
timesheetIdand at least one ofclockedInAt/clockedOutAt(valid ISO timestamps) /clockInTypeId. Passing"clockedOutAt": nullreopens that entry. The effectiveclockedOutAtmust be>= clockedInAt. Each entry may also include an optionalidempotencyKey(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": [] }timesheetslists every entry, in the same order as the request — newly updated rows plus any whoseidempotencyKeyhad already been applied (returned unchanged). When an entry fails validation, the error response includes a zero-basedindexidentifying which entry failed (e.g."message": "updates[1]: Timesheet not found", "index": 1).autoClockedOutTimesheetslists any prior open entries auto-clocked-out for entries whoseclockedInAtchanged (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 beADMIN) - Path Parameters:
timesheetId— the timesheet to delete
- Request Body: None
- Success Response:
204 No Contentwith an empty body. - Errors:
401— missing, invalid, or expired auth token403— caller is not anADMINemployee404— timesheet not found (also returned fortimesheetIds 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).