Skip to main content

Time Entry API

The time entry endpoints let you create, update, and manage time logs programmatically. This page covers the field semantics that are essential for correct integration — particularly the hours vs minutes distinction.

hours and minutes — Field Semantics

Time entries store duration in two related fields. Understanding their relationship prevents rounding surprises.

hours — canonical stored field

hours is the canonical stored representation. It is a text string representing a decimal hour value in quarter-hour increments (multiples of 0.25) up to 24:

"0.25" → 15 minutes
"1.5" → 1 hour 30 minutes
"2.0" → 2 hours
"7.75" → 7 hours 45 minutes

Parse it as a float when you need a number: parseFloat(entry.hours).

minutes — integer convenience field

minutes is an integer convenience representation of the same duration. When provided, the server derives hours by rounding to the nearest quarter-hour:

hours = round(minutes / 60, 0.25)
minutes sentExact fractionhours stored (nearest 0.25)
1502.500"2.5"
901.500"1.5"
450.750"0.75"
1001.667"1.75" ← rounds to nearest 0.25
100.167"0.25" ← rounds up to nearest 0.25

Providing either or both fields on POST

On POST /projects/:id/time-entries, you may provide either hours or minutes, or both. At least one is required.

ProvidedBehavior
minutes onlyhours = round(minutes / 60, 0.25)
hours onlyminutes = round(hours * 60)
Bothminutes takes precedence; hours is re-derived from minutes using the 0.25 rounding rule
Neither400 validation_error

Example — both fields provided, minutes wins:

// Request (hours="2.0" is ignored; minutes=100 takes precedence)
{
"userId": "user-uuid-...",
"workDate": "2026-05-26",
"minutes": 100,
"hours": "2.0"
}

// Stored result: hours = round(100/60, 0.25) = "1.75"
// Response
{
"hours": "1.75",
"minutes": 100,
...
}

Both fields are always present in every response:

{
"id": "te-uuid-...",
"hours": "2.5",
"minutes": 150,
...
}
Best practice

Send minutes as your primary input and let Align derive hours. This guarantees alignment to the quarter-hour grid without manual rounding.

Time Entry Status Values

StatusMeaning
draftCreated, not yet submitted
submittedAwaiting manager approval
lockedApproved and locked — immutable until unlocked
rejectedRejected by manager; can be edited and resubmitted
invoicedIncluded in a Stripe invoice — permanently immutable
note

When a manager approves a time entry, the status transitions to locked (not approved). locked entries contribute to the project's actual-cost rollup. Use status = "locked" in your local state machine to represent an approved entry.

Creating a Time Entry

curl -X POST https://app.alignsoft.us/api/v1/projects/<projectId>/time-entries \
-H "Authorization: Bearer $ALIGN_KEY" \
-H "Content-Type: application/json" \
-d '{
"userId": "user-uuid-...",
"workDate": "2026-05-26",
"minutes": 150,
"description": "Implemented OAuth login flow",
"entryId": "entry-uuid-...",
"billable": true,
"externalRef": {
"provider": "illumera",
"externalType": "time_log",
"externalId": "illumera-log-9876"
}
}'

Request fields:

FieldRequiredDescription
userIdYesID of the user logging time (must belong to the org)
workDateYesISO date or datetime for the day worked (e.g. "2026-05-26")
minutesOne of minutes/hoursDuration in minutes (integer 1–1440). If both provided, minutes takes precedence.
hoursOne of minutes/hoursDuration as a decimal text string, multiples of 0.25 up to 24 (e.g. "2.5").
descriptionNoFree-text note (max 2000 chars)
entryIdNoLink to a specific project entry (bug/feature/task)
releaseIdNoLink to a specific release
billableNoWhether the time is billable (defaults to true)
externalRefNoIdempotency key from your system

Response (201 Created):

{
"id": "te-uuid-...",
"organizationId": "org-uuid-...",
"projectId": "proj-uuid-...",
"userId": "user-uuid-...",
"entryId": "entry-uuid-...",
"workDate": "2026-05-26T00:00:00.000Z",
"hours": "2.5",
"minutes": 150,
"notes": "Implemented OAuth login flow",
"billable": true,
"status": "draft",
"source": "illumera",
"createdAt": "2026-05-26T09:00:00.000Z",
"updatedAt": "2026-05-26T09:00:00.000Z"
}

Updating a Time Entry

Only draft and rejected entries can be updated. Updating a rejected entry automatically moves it back to draft so it can be resubmitted.

curl -X PATCH https://app.alignsoft.us/api/v1/time-entries/<id> \
-H "Authorization: Bearer $ALIGN_KEY" \
-H "Content-Type: application/json" \
-d '{
"minutes": 180
}'

The response reflects the updated minutes and the newly derived hours ("3").

Time Entry Status Lifecycle

draft → submitted → locked (approved)
↘ rejected → (edit reverts to draft) → submitted
locked → unlocked (manager/admin) → draft → submitted → locked
locked → invoiced (billing run — immutable)
StatusCan edit?Next transition
draftYessubmitted (submit action)
submittedNolocked (approve) or rejected (reject)
lockedNodraft (unlock by manager/admin) or invoiced (billing run)
rejectedYes (reverts to draft on save)submitted (resubmit)
invoicedNo — permanent

Submitting, Approving, and Rejecting

ActionEndpointRequired role
Submit for approvalPOST /time-entries/:id/submitOwner or manager/admin
Approve (sets status → locked)POST /time-entries/:id/approvemanager or admin
RejectPOST /time-entries/:id/rejectmanager or admin
Unlock locked entryPOST /time-entries/:id/unlockmanager or admin

See Authorization for the full scope and permission matrix.

List and Filter

# Org-wide list (org-scoped key only)
GET /api/v1/time-entries?status=submitted&page=1&pageSize=25

# Project-scoped list
GET /api/v1/projects/:projectId/time-entries?userId=<id>&startDate=2026-05-01&endDate=2026-05-31

Both endpoints return paginated results:

{
"data": [ /* time entry rows */ ],
"page": 1,
"pageSize": 25,
"total": 142
}

Available query filters: userId, status, startDate (ISO date), endDate (ISO date).