Skip to content

Time Tracking

MC0RE edited this page Aug 18, 2026 · 2 revisions

Time Tracking

Manage time tracking entries in Teamleader Focus.

Overview

Time tracking entries record work done against a subject (company, contact, event, milestone, task, ticket). Entries can be created in three different time-recording variants depending on what information is available.

Access via Teamleader::timeTracking().

create() posts to timeTracking.add β€” not .create.

Three recording variants β€” started_at + duration, started_at + ended_at, or started_on + duration. The SDK validates the combination before the request.

Unknown filter keys throw since v2.2.0. timeTracking.list ignores filter keys it does not recognise and returns the complete unfiltered set with HTTP 200. A query scoped to two days returned all 32,985 entries in the account β€” silently. updated_since, invoiced and invoiceable are the confirmed cases; none of them is a filter on this endpoint.

The only sort field is starts_on. Earlier versions of this page said started_at, which is a filter prefix, not a sort field. The API ignores unrecognised sort fields, so sorting by started_at silently did nothing; it now throws.

Subject types differ between writing and filtering. nextgenTask can carry tracked time but is not accepted as a filter.subject.type.

Endpoint

timeTracking

Capabilities

Capability Supported
Pagination βœ… Supported
Filtering βœ… Supported
Sorting βœ… Supported (starts_on only)
Sideloading βœ… Supported (materials, relates_to)
Creation βœ… Supported
Update βœ… Supported
Deletion βœ… Supported

Methods

list(array $filters = [], array $options = [])

use McoreServices\TeamleaderSDK\Facades\Teamleader;

$entries = Teamleader::timeTracking()->list();

$entries = Teamleader::timeTracking()->list(
    ['user_id' => 'user-uuid', 'started_after' => '2025-04-01T00:00:00+02:00'],
    ['sort' => 'starts_on', 'sort_order' => 'desc', 'page_size' => 50]
);

// With sideloading
$entries = Teamleader::timeTracking()->withMaterials()->withRelations()->list();

Filter values are validated as well as filter keys β€” subject.type, each entry in subject_types, and relates_to.type are all checked before the request is sent.

A null filter value is treated as "not set" rather than as an error, so ['user_id' => null] is skipped rather than throwing.


info(mixed $id, mixed $includes = null)

$entry = Teamleader::timeTracking()->info('entry-uuid');
$entry = Teamleader::timeTracking()->info('entry-uuid', 'materials');
$entry = Teamleader::timeTracking()->withMaterials()->info('entry-uuid');

create(array $data)

Posts to timeTracking.add. Three recording variants β€” pass exactly one combination:

Variant 1: started_at + duration (seconds)

Teamleader::timeTracking()->create([
    'started_at'   => '2025-05-12T09:00:00+02:00',
    'duration'     => 3600,   // seconds
    'subject'      => ['type' => 'company', 'id' => 'company-uuid'],
    'work_type_id' => 'work-type-uuid',
    'description'  => 'Client meeting',
    'invoiceable'  => true,
]);

Variant 2: started_at + ended_at

Teamleader::timeTracking()->create([
    'started_at'   => '2025-05-12T09:00:00+02:00',
    'ended_at'     => '2025-05-12T11:00:00+02:00',
    'subject'      => ['type' => 'ticket', 'id' => 'ticket-uuid'],
    'work_type_id' => 'work-type-uuid',
]);

Variant 3: started_on + duration (duration tracking mode)

Teamleader::timeTracking()->create([
    'started_on'   => '2025-05-12',     // date only
    'duration'     => 7200,
    'subject'      => ['type' => 'nextgenTask', 'id' => 'task-uuid'],
    'work_type_id' => 'work-type-uuid',
]);

invoiceable is a field you can set on an entry. It is not a filter β€” see the note in the Overview.


update(mixed $id, array $data)

Injects id into the request body.

Teamleader::timeTracking()->update('entry-uuid', ['description' => 'Updated', 'invoiceable' => false]);

delete(mixed $id)

Teamleader::timeTracking()->delete('entry-uuid');

resume(string $id, ?string $startedAt = null)

Resumes a timer from a previously stopped entry. $startedAt defaults to now.

Teamleader::timeTracking()->resume('entry-uuid');
Teamleader::timeTracking()->resume('entry-uuid', '2025-05-12T14:00:00+02:00');

Helper Methods

Method Description
forUser(string $userId) Filter by user_id
forSubject(string $id, string $type) Filter by subject β€” type validated against the filter list
forSubjectTypes(array $types) Filter by subject_types β€” each entry validated
betweenDates(string $start, string $end) started_after + started_before
endedBetween(string $start, string $end) ended_after + ended_before
relatedTo(string $id, string $type) relates_to β€” type validated separately
withMaterials() Fluent: sideload materials
withRelations() Fluent: sideload relates_to

All filter helpers accept an optional $options array as their last parameter.

Teamleader::timeTracking()->forUser('user-uuid');
Teamleader::timeTracking()->forSubject('ticket-uuid', 'ticket');
Teamleader::timeTracking()->betweenDates('2025-05-01T00:00:00+02:00', '2025-05-31T23:59:59+02:00');
Teamleader::timeTracking()->relatedTo('project-uuid', 'nextgenProject');

The fluent include methods relied on a with() method that did not exist before v2.2.0 β€” withMaterials() and withRelations() raised Error: Call to undefined method in every earlier release.


Subject Types

The set accepted when writing an entry is wider than the set accepted as a list filter. Verified against @teamleader/focus-api-specification.

Type create() / update() filter.subject / subject_types
company βœ… βœ…
contact βœ… βœ…
event βœ… βœ…
milestone βœ… βœ…
ticket βœ… βœ…
todo βœ… βœ…
nextgenTask βœ… ❌

To find time tracked against project work, filter on relates_to instead:

// Not possible β€” nextgenTask is not a filter value
Teamleader::timeTracking()->forSubject('task-uuid', 'nextgenTask');
// InvalidArgumentException: ... Time can be tracked against a nextgenTask, but
// the API does not accept it as a filter value. Use relates_to instead.

// Do this instead
Teamleader::timeTracking()->relatedTo('project-uuid', 'nextgenProject');

The spec's update schema omits nextgenTask while add includes it. That asymmetry looks more like a spec inconsistency than a real rule, so the SDK accepts it on both writes and lets the API be the backstop.

relates_to Types (filter only)

milestone, project, nextgenProject, nextgenProjectGroup

Note that project here is the legacy project system and nextgenProject the current one β€” the same split described on Projects and Legacy Projects.


Filters

Verified against the specification. This is the complete set β€” anything else throws.

Filter Type Description
ids array Filter by entry UUIDs. A single string is wrapped for you
user_id string Filter by user UUID
started_after string ISO 8601 datetime β€” includes entries that started on the given date
started_before string ISO 8601 datetime
ended_after string ISO 8601 datetime
ended_before string ISO 8601 datetime
subject object {type, id} β€” both required, type validated
subject_types array Array of subject type strings, each validated
relates_to object {type, id} β€” both required, type validated

Not filters, despite appearances:

Key Why people try it What to do instead
updated_since Common on other resources Use started_after / ended_after
invoiced It is a field on the entry Filter client-side after fetching
invoiceable Settable on create/update Filter client-side after fetching

Sorting

Field Description
starts_on The date the entry started

This is the only sort field the API declares. Anything else throws.

$entries = Teamleader::timeTracking()->list([], [
    'sort'       => 'starts_on',
    'sort_order' => 'desc',
]);

Sideloading

Include Description
materials Materials linked to the entry
relates_to Project, milestone, or group the entry relates to

Sent as includes (plural) in the request body.


Usage Examples

Sync entries for a date window

Because there is no total count in the response, page until a page comes back shorter than the requested size.

$all  = [];
$page = 1;

do {
    $response = Teamleader::timeTracking()->list(
        [
            'started_after'  => '2026-08-01T00:00:00+02:00',
            'started_before' => '2026-08-31T23:59:59+02:00',
        ],
        ['page_size' => 100, 'page_number' => $page]
    );

    $all = array_merge($all, $response['data']);
    $page++;
} while (count($response['data']) === 100);

Scoping by date is important. Without a filter this endpoint returns every entry in the account, which on an established account is tens of thousands of records.

Find unbilled time for a project

invoiced is not a filter, so fetch by relation and filter in PHP:

$entries = Teamleader::timeTracking()->relatedTo('project-uuid', 'nextgenProject', [
    'page_size' => 100,
]);

$unbilled = array_filter(
    $entries['data'],
    fn ($entry) => ($entry['invoiceable'] ?? false) && ! ($entry['invoiced'] ?? false)
);

Log an hour against a ticket

Teamleader::timeTracking()->create([
    'started_at'   => now()->toIso8601String(),
    'duration'     => 3600,
    'subject'      => ['type' => 'ticket', 'id' => 'ticket-uuid'],
    'work_type_id' => 'work-type-uuid',
    'description'  => 'Investigated the reported sync failure',
    'invoiceable'  => true,
]);

Error Handling

use InvalidArgumentException;

// Unsupported filter key β€” the case that returned 32,985 rows
try {
    Teamleader::timeTracking()->list(['updated_since' => '2026-08-01T00:00:00+02:00']);
} catch (InvalidArgumentException $e) {
    // "Invalid filter key 'updated_since' for timeTracking.list. Supported filters:
    //  ids, user_id, started_after, started_before, ended_after, ended_before,
    //  subject, subject_types, relates_to."
}

// invoiced is a field, not a filter
try {
    Teamleader::timeTracking()->list(['invoiced' => false]);
} catch (InvalidArgumentException $e) {
    // "Invalid filter key 'invoiced' for timeTracking.list. ..."
}

// Unsupported sort field
try {
    Teamleader::timeTracking()->list([], ['sort' => 'started_at']);
} catch (InvalidArgumentException $e) {
    // 'Invalid sort field: started_at. timeTracking.list accepts: starts_on.'
}

// Subject type valid for writing but not for filtering
try {
    Teamleader::timeTracking()->forSubject('task-uuid', 'nextgenTask');
} catch (InvalidArgumentException $e) {
    // '... Use relates_to instead.'
}

// Invalid subject type
try {
    Teamleader::timeTracking()->forSubject('uuid', 'deal');
} catch (InvalidArgumentException $e) {
    // 'Invalid subject type for the timeTracking.list filter: deal. Must be one of:
    //  company, contact, event, milestone, ticket, todo.'
}

// Invalid relates_to type
try {
    Teamleader::timeTracking()->relatedTo('uuid', 'deal');
} catch (InvalidArgumentException $e) {
    // 'Invalid relates_to type: deal. Must be one of: milestone, project,
    //  nextgenProject, nextgenProjectGroup.'
}

Related Resources

  • Timers β€” Start/stop a live running timer that creates entries on stop
  • Work Types β€” work_type_id on entries
  • Tasks β€” Time tracked against standalone tasks
  • Project Tasks β€” Time tracked against project tasks
  • Tickets β€” Time tracked against support tickets
  • Projects β€” Filter project time via relates_to with nextgenProject
  • Legacy Projects β€” Filter legacy project time via relates_to with project

Clone this wiki locally