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
402with reasonfeature_disabled. - The time-entry endpoints and the three time-summary endpoints also need the Illumera add-on. Without it they return
403with{ "error": "addon_required", "addon": "illumera" }.GET /projects/{id}/cost-summarydoes not need it. - Permissions:
read:time_entryto read,write:time_entryto create, update, submit, approve, reject and unlock. See Authorization & Scopes. DELETE /time-entries/{id}checksdelete:time_entry. The organisation default includes it. Onlydraftentries can be deleted (others return409 status_conflict), and only by the entry's owner or a manager or admin, judged by the key creator's role.PATCHwith anentryIdfrom another project returns422 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 sent | Exact hours | hours stored |
|---|---|---|
150 | 2.500 | "2.5" |
90 | 1.500 | "1.5" |
45 | 0.750 | "0.75" |
100 | 1.667 | "1.75" |
10 | 0.167 | "0.25" |
Sending one or both
On POST /projects/{id}/time-entries and PATCH /time-entries/{id}, send minutes, hours, or both.
| You send | Result |
|---|---|
minutes only | hours is derived from minutes |
hours only | minutes = round(hours × 60) |
| Both | minutes 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.
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
| Status | Meaning |
|---|---|
draft | Logged, not yet submitted |
submitted | Waiting for a manager or admin |
locked | Approved through the API |
approved | Approved in Align |
rejected | Rejected; 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"
}
}'
| Field | Required | Description |
|---|---|---|
userId | Yes | The person who did the work. Must belong to the organisation, or 422 user_not_found. |
workDate | Yes | ISO date or date-time of the day worked |
minutes | One of minutes / hours | Integer, 1–1440 |
hours | One of minutes / hours | Decimal hours, a multiple of 0.25, up to 24 |
description | No | Up to 2,000 characters. Stored and returned as notes. |
entryId | No | An entry in the same project, or 422 entry_not_found |
releaseId | No | A release to attribute the time to |
billable | No | Defaults to true |
externalRef | No | Your 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
| Status | Can edit? | Next step |
|---|---|---|
draft | Yes | Submit |
submitted | No | Approve or reject |
locked / approved | No | Unlock, unless invoiced |
rejected | Yes (editing returns it to draft) | Edit, then submit |
Submitting, approving, rejecting and unlocking
| Action | Endpoint | Who |
|---|---|---|
| Submit a draft | POST /time-entries/{id}/submit | The key's creator owns the entry, or is a manager or admin |
Approve (status becomes locked) | POST /time-entries/{id}/approve | The key's creator is a manager or admin |
| Reject | POST /time-entries/{id}/reject with { "reason": "..." } | The key's creator is a manager or admin. reason is required (1–1,000 characters). |
| Unlock | POST /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
| Endpoint | Returns |
|---|---|
GET /projects/{id}/time-summary | totalMinutes, submittedMinutes, approvedMinutes, rejectedMinutes, billableMinutes and entryCount for the project. Accepts userId, status, startDate, endDate. |
GET /projects/{id}/releases/{releaseId}/time-summary | The same, for one release |
GET /users/{id}/time-summary | The same, for one person across the organisation (organisation-scoped key only) |
GET /projects/{id}/cost-summary | totalApprovedMinutes and billableApprovedMinutes, counting approved, locked and invoiced time. Accepts userId, status (one of approved, locked or invoiced), startDate, endDate. |