Skip to main content

Time Entry API

The time entry endpoints let you log, update and approve time programmatically. This page covers the field rules you need for a correct integration, especially how hours and minutes relate.

Requirements​

  • Time tracking must be on for the organisation. Otherwise these endpoints return 402 with reason feature_disabled.
  • The time-entry endpoints and the three time-summary endpoints also need the Illumera add-on. Without it they return 403 with { "error": "addon_required", "addon": "illumera" }. GET /projects/{id}/cost-summary does not need it.
  • Permissions: read:time_entry to read, write:time_entry to create, update, submit, approve, reject and unlock. See Authorization & Scopes.
  • DELETE /time-entries/{id} checks delete:time_entry. The organisation default includes it. Only draft entries can be deleted (others return 409 status_conflict), and only by the entry's owner or a manager or admin, judged by the key creator's role.
  • PATCH with an entryId from another project returns 422 project_mismatch.

hours and minutes​

Time entries store a duration in two fields.

hours: the stored value​

hours is a text string holding decimal hours, in quarter-hour steps (multiples of 0.25), greater than 0 and at most 24:

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

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

minutes: whole minutes​

minutes is an integer from 1 to 1440. When you send minutes, Align derives hours by rounding to the nearest quarter-hour:

hours = round(minutes / 60 × 4) / 4
minutes sentExact hourshours stored
1502.500"2.5"
901.500"1.5"
450.750"0.75"
1001.667"1.75"
100.167"0.25"

Sending one or both​

On POST /projects/{id}/time-entries and PATCH /time-entries/{id}, send minutes, hours, or both.

You sendResult
minutes onlyhours is derived from minutes
hours onlyminutes = round(hours × 60)
Bothminutes wins; hours is derived from it and the hours you sent is ignored
Neither (on create)400 validation_error

hours may be sent as a string or a number. A value that is not a multiple of 0.25, is 0 or less, or is over 24 returns 400 validation_error.

tip

Send minutes and let Align derive hours. That keeps you on the quarter-hour grid without rounding yourself.

minutes can be null on older time entries. Use hours when minutes is missing.

Status values​

StatusMeaning
draftLogged, not yet submitted
submittedWaiting for a manager or admin
lockedApproved through the API
approvedApproved in Align
rejectedRejected; can be edited and resubmitted

locked and approved both mean approved: the entry has a lockedAt time, cannot be edited, and counts towards the project's actual cost. Treat them the same.

An entry that has been invoiced has a non-null invoicedAt. It can never be edited, deleted or unlocked; edits return 409 immutable.

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-10-13",
"minutes": 150,
"description": "Implemented OAuth login flow",
"entryId": "entry-uuid-...",
"billable": true,
"externalRef": {
"provider": "illumera",
"externalType": "time_log",
"externalId": "illumera-log-9876"
}
}'
FieldRequiredDescription
userIdYesThe person who did the work. Must belong to the organisation, or 422 user_not_found.
workDateYesISO date or date-time of the day worked
minutesOne of minutes / hoursInteger, 1–1440
hoursOne of minutes / hoursDecimal hours, a multiple of 0.25, up to 24
descriptionNoUp to 2,000 characters. Stored and returned as notes.
entryIdNoAn entry in the same project, or 422 entry_not_found
releaseIdNoA release to attribute the time to
billableNoDefaults to true
externalRefNoYour system's ID. A second call with the same externalRef returns the existing time entry with 200 instead of creating another.

Align fixes the entry's rate when it is created, from the person's labour category, so later rate changes do not alter it.

Response (201 Created, abbreviated):

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

source is illumera when externalRef.provider is illumera, otherwise align. In GET responses, hourlyRateSnapshot is shown only when the key's creator is a manager or admin; otherwise it is null.

Updating a time entry​

Only draft and rejected entries can be updated; others return 409 status_conflict. Updating a rejected entry 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 shows "minutes": 180 and "hours": "3". You can update workDate, minutes, hours, description, billable, entryId and releaseId; any other field returns 400 validation_error.

Status lifecycle​

draft → submitted → locked (approved through the API) / approved (approved in Align)
↘ rejected → (edit returns it to draft) → submitted
locked or approved → unlock (manager or admin) → draft
StatusCan edit?Next step
draftYesSubmit
submittedNoApprove or reject
locked / approvedNoUnlock, unless invoiced
rejectedYes (editing returns it to draft)Edit, then submit

Submitting, approving, rejecting and unlocking​

ActionEndpointWho
Submit a draftPOST /time-entries/{id}/submitThe key's creator owns the entry, or is a manager or admin
Approve (status becomes locked)POST /time-entries/{id}/approveThe key's creator is a manager or admin
RejectPOST /time-entries/{id}/reject with { "reason": "..." }The key's creator is a manager or admin. reason is required (1–1,000 characters).
UnlockPOST /time-entries/{id}/unlock with an optional { "reason": "..." }The key's creator is a manager or admin

Approve and reject need the entry to be submitted; submit needs it to be draft. Otherwise they return 409 status_conflict. Unlock returns 409 not_locked for an entry that is not locked, and 409 immutable for an invoiced one.

See Authorization & Scopes for the exact errors.

Listing and filtering​

# Every project (organisation-scoped key only)
GET /api/v1/time-entries?status=submitted&page=1&pageSize=25

# One project
GET /api/v1/projects/{projectId}/time-entries?userId=<id>&startDate=2026-10-01&endDate=2026-10-31

Both return pages, newest workDate first:

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

Filters: userId, status, startDate and endDate (inclusive, ISO dates). The organisation-wide list also accepts projectId.

Summaries​

EndpointReturns
GET /projects/{id}/time-summarytotalMinutes, submittedMinutes, approvedMinutes, rejectedMinutes, billableMinutes and entryCount for the project. Accepts userId, status, startDate, endDate.
GET /projects/{id}/releases/{releaseId}/time-summaryThe same, for one release
GET /users/{id}/time-summaryThe same, for one person across the organisation (organisation-scoped key only)
GET /projects/{id}/cost-summarytotalApprovedMinutes and billableApprovedMinutes, counting approved, locked and invoiced time. Accepts userId, status (one of approved, locked or invoiced), startDate, endDate.