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.
Before you start
Section titled “Before you start”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.
Making a request
Section titled “Making a request”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.
minutesis a whole number of minutes, not hours — 90, not 1.5. - Dates are dates, timestamps are UTC. A log’s
dateis the day the work was done,YYYY-MM-DD, with no time part.createdOn,updatedOnanddeletedOnare 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.
userNameanduserEmailcome 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
userMakingChangein the body; deletes and time type moves carry the same name in anx-timelog-usermakingchangeheader. It is recorded ascreatedBy/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
deletedOnanddeletedBy. 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.
When something goes wrong
Section titled “When something goes wrong”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.
Time logs
Section titled “Time logs”Query time logs
Section titled “Query time logs”GET {root}{organisationId}/timelog/queryPOST {root}{organisationId}/timelog/queryThe 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 |
Filtering by several projects
Section titled “Filtering by several projects”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:00Deleted 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=500Sample 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.
Read a single time log
Section titled “Read a single time log”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.
Read the time logged on a work item
Section titled “Read the time logged on a work item”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.
Create a time log
Section titled “Create a time log”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.
Create the same entry for several people
Section titled “Create the same entry for several people”POST {root}{organisationId}/timelogsOne 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.
Update a time log
Section titled “Update a time log”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 a time log
Section titled “Delete a time log”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 every log on a work item
Section titled “Delete every log on a work item”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
Section titled “Time types”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).
List them
Section titled “List them”GET {root}{organisationId}/timetype/project All types in the organisationGET {root}{organisationId}/timetype/project/{projectId} Types usable in one projectGET {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.
Create or update one
Section titled “Create or update one”POST {root}{organisationId}/timetype CreatePOST {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 one
Section titled “Delete one”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.
Move entries to another type
Section titled “Move entries to another type”POST {root}{organisationId}/timetype/{sourceTimeTypeId}/move-timelogsRepoints 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.
Analytics dashboard
Section titled “Analytics dashboard”GET {root}{organisationId}/analytics/dashboardPOST {root}{organisationId}/analytics/dashboardThe 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.
Allowance
Section titled “Allowance”GET {root}{organisationId}/usageWhat 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 }]}Putting it to work
Section titled “Putting it to work”An incremental extract
Section titled “An incremental extract”The pattern most warehouses want: ask for everything that has changed since the last run,
including deletions, and upsert on timeLogId.
$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.txtThe 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 BI
Section titled “Power BI”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 TableFor 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.
Postman
Section titled “Postman”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.
Getting help
Section titled “Getting help”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.