Skip to content

REST API reference

The Time Log REST API is how everything the extension records gets out of it, and how time recorded elsewhere gets in. It is the same API the extension itself uses, so anything you can see on a Time Log page you can read from your own code: a nightly extract into a warehouse, a Power BI refresh, an importer that carries time over from the system your team used before.

This page covers reading and writing time logs and time types, which is what an integration normally needs. The extension also exposes admin endpoints — settings, per-user preferences, timers, the weekly shortfall report — which are documented in the Time Log REST API document supplied with your licence.

Three things, all on the Time Log Admin page (Project settings → Extensions → Time Log Admin).

Your organisation ID is in the top right corner of the page, with a button that copies it. It is a GUID, it never changes, and every URL below starts with it — it is what scopes a request to your data and nobody else’s.

Your REST API key is the Time Log REST API Key setting, issued with your licence agreement. It is the only credential the API takes: no user sign-in, no token exchange. Send it in the x-functions-key header on every request, as shown below; the same key works for every endpoint on this page.

The root URL is https://boznet-timelogapi.azurewebsites.net/api/ for the hosted service. An on-premises deployment has its own, which is whatever is in the Time Log REST API URL setting on the same page. There is also a sandbox at https://boznet-timelogapi-test.azurewebsites.net/api/, holding separate data, which is the place to try a destructive call before pointing it at live.

Every endpoint has the same shape: the root URL, your organisation ID, then the path.

{root URL}{organisationId}/{path}

The API key goes in the x-functions-key header. Requests and responses are JSON throughout, and a request with a body needs Content-Type: application/json.

curl -H "x-functions-key: YOUR-API-KEY" \
"https://boznet-timelogapi.azurewebsites.net/api/YOUR-ORGANISATION-ID/timelog/query?week=2026-W02"

A few conventions hold everywhere:

  • Time is minutes. minutes is a whole number of minutes, not hours — 90, not 1.5.
  • Dates are dates, timestamps are UTC. A log’s date is the day the work was done, YYYY-MM-DD, with no time part. createdOn, updatedOn and deletedOn are UTC timestamps; a value you send without a time zone is read as UTC, not as your local time.
  • Weeks are ISO weeks, written 2026-W02. Monday starts the week.
  • Users are Azure DevOps user IDs — the GUID, not the display name. userName and userEmail come along for the ride so a report has something to print, and are refreshed from what you send.
  • Say who is making the change. Creates and updates carry userMakingChange in the body; deletes and time type moves carry the same name in an x-timelog-usermakingchange header. It is recorded as createdBy / updatedBy / deletedBy, so an entry an integration touched can be told apart from one a person edited.
  • Deletes are soft. A deleted log keeps its row, stamped deletedOn and deletedBy. It stops appearing in ordinary queries but can still be read back, which is what lets a downstream warehouse learn that a row it already copied has since been removed. The one thing that removes a log for good is deleting the time type it belongs to.

Failures come back as a 4xx status with a JSON body naming what happened:

{
"title": "The filter specified is not valid",
"error": {
"number": 10005,
"message": "The filter specified is not valid. No filter specified",
"severity": 2
}
}

severity is 3 critical, 2 error, 1 warning, 0 information. number is stable, so code can branch on it. These are the numbers the endpoints on this page return; the admin endpoints have a few of their own, and the numbering is not contiguous:

Number Meaning
10001 The organisation ID is missing from the URL
10002 The time type description was not found in this organisation or project
10003 No time log with that ID
10004 The evaluation allowance for this month is used up
10005 The query filter is not valid, or no filter was given
10006 Warning: few evaluation logs left this month
10008 Warning: few pay-as-you-go logs left
10009 All pay-as-you-go logs have been used
10010 The time log failed validation — the message says which field
20001 The time type cannot be deleted because logs still point at it
20002 The time type failed validation
20003 The work item ID is missing or less than 1
20004 No time type with that ID
20005 A time type with that description already exists
20006–20009 The target of a time type move is missing, the same as the source, disabled, or in another project

A 401 means the x-functions-key header is missing or wrong. Warnings (10006, 10008) arrive on a successful 201 rather than an error: the logs were created, and the body also tells you the allowance is running low.

GET {root}{organisationId}/timelog/query
POST {root}{organisationId}/timelog/query

The workhorse. GET takes the filters as query string parameters; POST takes exactly the same names in a JSON body, which is easier when you are passing a list of team members. Both return an array of time logs.

At least one filter is required. A query with none is rejected with error 10005, rather than quietly returning every log your organisation has ever recorded. Filters combine with AND.

Filter Matches
workItemId Logs against one work item
projectId Logs in one project (GUID)
projectIds Logs in any of several projects — see below
fromDate, toDate The day the work was done, inclusive
week One ISO week, e.g. 2026-W02: the logs dated in it, Monday to Sunday
userId One user, by Azure DevOps GUID
userName One user, by display name
teamMembers Any of a list of user GUIDs — a team, or any set of people
timeTypeDescription One time type, by its exact description
createdOnFromDate, createdOnToDate, createdBy When the entry was recorded, and by whom
updatedOnFromDate, updatedOnToDate, updatedBy When it was last amended, and by whom
deletedOnFromDate, deletedOnToDate, deletedBy When it was deleted, and by whom
changedOnFromDate, changedOnToDate, changedBy Any of the three above — created, updated or deleted

projectId takes one project and is unchanged; projectIds takes as many as you need. Over the query string both projectIds and teamMembers are repeated once per value rather than comma-separated:

GET {root}{organisationId}/timelog/query?projectIds=31b3d8f8-…&projectIds=5849b5c3-…

In a JSON body they are plain arrays, which is usually easier:

{ "fromDate": "2026-01-01", "projectIds": ["31b3d8f8-…", "5849b5c3-…"] }

Send one or the other. Supplying projectId and projectIds together narrows to logs satisfying both, in the same way userId and teamMembers do — so a request naming different projects in each returns nothing. A log recorded without a project is never matched by either.

Note the difference between the date filters and the timestamp ones. fromDate and toDate ask “which days was this work done on”; changedOnFromDate asks “what has happened in the system since I last looked”. An incremental extract wants the second:

GET {root}{organisationId}/timelog/query?changedOnFromDate=2026-01-31T02:00:00

Deleted entries are left out unless you ask for them, and any of the deleted… or changed… filters counts as asking. That is deliberate — it is what lets the query above return “these three logs are new, and this one has gone” in a single call, so a warehouse that copied the removed row can retire it.

Paging is off by default: without page, every match is returned. Pass page (1-based) and pageSize to page through instead, newest day first, with the log ID breaking ties so a row cannot appear on two pages:

GET {root}{organisationId}/timelog/query?fromDate=2026-01-01&page=1&pageSize=500

Sample response:

[
{
"timeLogId": "ca42579e-6a00-464c-bcaa-7987cbf374bb",
"comment": "Fixing the import",
"week": "2026-W02",
"timeTypeId": "3e949753-5d69-61a3-b513-76cc0505b28d",
"timeTypeDescription": "Development",
"minutes": 120,
"date": "2026-01-08",
"userId": "a96193a0-51e5-482b-9bcb-b1efcef4b170",
"userName": "Anna Smith",
"userEmail": "anna@example.com",
"workItemId": 3814,
"projectId": "31b3d8f8-1092-4f88-9847-7cc3b4f0eb9d",
"createdOn": "2026-01-08T17:04:11.98",
"createdBy": "Anna Smith",
"updatedOn": null,
"updatedBy": null,
"deletedOn": null,
"deletedBy": null
}
]

The POST form takes the same filters as a body, which avoids a very long URL when you are naming a team:

{
"fromDate": "2026-01-01",
"toDate": "2026-01-31",
"teamMembers": [
"a96193a0-51e5-482b-9bcb-b1efcef4b170",
"cd5e9635-101d-6074-afaa-50c2da3e0eae"
]
}

Both return 200, or 400 with error 10005 if the filter is unusable.

GET {root}{organisationId}/timelog/{timeLogId}

Returns one entry, in the shape above. 404 with error 10003 if there is no such log in your organisation.

GET {root}{organisationId}/timelog/project/{projectId}/workitem/{workItemId}

Every entry against one work item — the same list the Time Log tab shows. Returns an array in the shape above, empty if nothing has been logged.

POST {root}{organisationId}/timelog
Field
minutes Required. Whole minutes.
date Required. YYYY-MM-DD, the day the work was done.
timeTypeDescription Required. Must match an existing time type in the organisation, or one belonging to projectId.
workItemId Required. The Azure DevOps work item.
userId Required. The Azure DevOps user GUID the time is for.
userName, userEmail The user’s display name and address, used to keep the user record current.
projectId Required. The project the work item is in.
comment Optional.
userMakingChange Who is recording it, stored as createdBy.
{
"minutes": 60,
"date": "2026-01-15",
"timeTypeDescription": "Development",
"comment": "Imported from the old timesheet",
"workItemId": 3814,
"userId": "a96193a0-51e5-482b-9bcb-b1efcef4b170",
"userName": "Anna Smith",
"userEmail": "anna@example.com",
"projectId": "31b3d8f8-1092-4f88-9847-7cc3b4f0eb9d",
"userMakingChange": "Timesheet import"
}

201 on success, with the new IDs in the body and a Location header pointing at the entry:

{ "logsCreated": ["ca42579e-6a00-464c-bcaa-7987cbf374bb"] }

When the organisation’s remaining allowance is getting low the same 201 also carries a title and error block (10006 or 10008) saying how much is left — the logs were still created. 400 means validation failed (10010), or the allowance is exhausted (10004, 10009); 404 means the time type description does not exist (10002).

Each created log consumes one record from the organisation’s allowance, so an import that runs twice costs twice. Query before you create if the source might already have been loaded.

POST {root}{organisationId}/timelogs

One work item, one date, one duration, several users — a two-hour meeting the whole team attended, without a call per attendee. Same fields as a single create, except that the per-user ones move into a users array:

{
"minutes": 60,
"date": "2026-01-15",
"timeTypeDescription": "Meetings",
"comment": "Sprint planning",
"workItemId": 343,
"projectId": "5849b5c3-60e7-4dda-96a0-f73580df837e",
"users": [
{ "userId": "cd5e9635-101d-6074-afaa-50c2da3e0eae", "userName": "Anna Smith", "userEmail": "anna@example.com" },
{ "userId": "cd5e9635-101d-6074-afaa-50c2da3e0ead", "userName": "Ben Okafor", "userEmail": "ben@example.com" }
],
"userMakingChange": "Anna Smith"
}

201, with every new ID in logsCreated. One record of allowance is consumed per user.

POST {root}{organisationId}/timelog/{timeLogId}

Send the whole entry, not just the changed fields — the same body as a create, with userMakingChange recorded as updatedBy. 200 on success, 404 (10003) if the log does not exist.

DELETE {root}{organisationId}/timelog/{timeLogId}

Pass the person responsible in the x-timelog-usermakingchange header. 204 on success. The delete is soft: the entry stops appearing in ordinary queries, keeps its deletedOn and deletedBy, and can still be found with the deleted… and changed… filters. Deleting does not return the record to your allowance.

DELETE {root}{organisationId}/timelog/project/{projectId}/workitem/{workItemId}

Soft-deletes all of that work item’s entries in one call, attributed through the same header. 204, whether or not there was anything to delete.

Time types are the categories time is logged against — Development, Meetings, Documentation. A type either belongs to one project or is available to all of them (projectId: null).

GET {root}{organisationId}/timetype/project All types in the organisation
GET {root}{organisationId}/timetype/project/{projectId} Types usable in one project
GET {root}{organisationId}/timetype/{timeTypeId} One type
{
"timeTypeId": "ca42579e-6a00-464c-bcaa-7987cbf374bb",
"description": "Development",
"projectId": null,
"isDefaultForProject": false,
"disabled": false
}

projectId is null for a type available everywhere. isDefaultForProject marks the one preselected when someone logs time on that project. A disabled type keeps its history but is no longer offered for new entries.

POST {root}{organisationId}/timetype Create
POST {root}{organisationId}/timetype/{timeTypeId} Update
{
"description": "Code review",
"projectId": "31b3d8f8-1092-4f88-9847-7cc3b4f0eb9d",
"isDefaultForProject": false
}

Omit projectId (or send null) to make the type available to every project. 201 on create, 200 on update. 400 if the description is empty (20002) or already taken (20005).

DELETE {root}{organisationId}/timetype/{timeTypeId}

204 on success. A type with live time logged against it cannot be deleted — that comes back 400 with error 20001, because deleting it would strand the history. Move the entries to another type first.

POST {root}{organisationId}/timetype/{sourceTimeTypeId}/move-timelogs

Repoints every entry on the source type — including soft-deleted ones — at another type, which is how a type is drained before it is deleted, and how a mistaken category is corrected in bulk.

{ "targetTimeTypeId": "3e949753-5d69-61a3-b513-76cc0505b28d" }

The target must be active, different from the source, and either in the same project as the source or available to all projects. Name the person responsible in x-timelog-usermakingchange.

200 with the number moved: { "movedCount": 3 }. Any running timers on the source type are repointed too, so nobody is left timing against a type that is about to disappear; they are not counted in movedCount, which reports time log entries. 404 if the organisation or either type is unknown, 400 (20006–20009) if the target is not a legitimate destination.

GET {root}{organisationId}/analytics/dashboard
POST {root}{organisationId}/analytics/dashboard

The figures behind View Dashboard on the Time Log Summary page, aggregated on the server: a summary of totals, plus hours by day, user, category and month. It takes fromDate, toDate, week, userId, userName, teamMembers, timeTypeDescription, projectId and projectIds, meaning the same as they do for the query, and unlike the query none are required.

Over the query string the dashboard’s lists are comma-separated, not repeated:

GET {root}{organisationId}/analytics/dashboard?projectIds=31b3d8f8-…,5849b5c3-…

In a POST body projectIds and teamMembers are plain arrays, as for the query. 200 on success; 400 if a list holds something that is not a GUID.

GET {root}{organisationId}/usage

What has been bought and what is left — the same figures as View Usage on the Time Log Admin page. Worth checking from an importer before a large load, so it fails before it half-finishes.

{
"totalPurchased": 10000,
"totalUsed": 12,
"totalRemaining": 9988,
"purchases": [{ "purchaseDate": "2026-01-04T11:22:14", "quantity": 10000 }],
"usage": [{ "year": 2026, "month": 1, "usage": 12 }]
}

The pattern most warehouses want: ask for everything that has changed since the last run, including deletions, and upsert on timeLogId.

Terminal window
$headers = @{ "x-functions-key" = $env:TIMELOG_API_KEY }
$root = "https://boznet-timelogapi.azurewebsites.net/api"
$org = $env:TIMELOG_ORGANISATION_ID
$since = (Get-Content .\last-run.txt) # e.g. 2026-01-31T02:00:00
# Taken before the call, not after: anything recorded while the query runs must
# fall inside the next run's window rather than between the two.
$startedAt = (Get-Date).ToUniversalTime().ToString("s")
$logs = Invoke-RestMethod -Headers $headers `
-Uri "$root/$org/timelog/query?changedOnFromDate=$since"
# Every row, deletions included. A row with a deletedOn is the only signal that
# something already copied has gone, so it has to reach the warehouse: upsert on
# timeLogId, and retire the rows that come back deleted.
$logs | Export-Csv .\changed.csv -NoTypeInformation
$startedAt | Set-Content .\last-run.txt

The watermark is stamped from the time the run started, not the time it finished, or work recorded while the extract was running falls into the gap between the two windows and is never picked up. The overlap that creates is harmless: rows are upserted by timeLogId, so seeing one twice changes nothing.

Power Query can call the API directly, which is what the chart on the REST API page is doing. In a blank query:

let
Root = "https://boznet-timelogapi.azurewebsites.net/api/",
Org = "YOUR-ORGANISATION-ID",
Source = Json.Document(
Web.Contents(
Root & Org & "/timelog/query",
[
Query = [ fromDate = "2026-01-01", toDate = "2026-12-31" ],
Headers = [ #"x-functions-key" = "YOUR-API-KEY" ]
]
)
),
Table = Table.FromRecords(Source)
in
Table

For a report you refresh in the Power BI service, set the key in the data source credentials instead of in the query text, and the scheduled refresh picks it up from there.

The collection screenshot on the REST API page uses a Postman environment with root, organisationId and apiKey as variables, and a collection-level header of x-functions-key: {{apiKey}}. Every request is then {{root}}{{organisationId}}/…, and pointing the whole collection at the sandbox is a matter of switching environment.

If a call is not behaving as this page describes, send us the request — URL, body, and the status and body that came back — at info@timelogextension.com. The number in the error body is the quickest thing for us to work from.