/**
* Clockify API Client
* Handles all HTTP communication with Clockify's REST API
*/
const API_BASE = 'https://api.clockify.me/api/v1';
const REPORTS_API_BASE = 'https://reports.api.clockify.me/v1';
export interface ClockifyConfig {
apiKey: string;
workspaceId?: string;
}
export interface TimeEntry {
id: string;
description: string;
userId: string;
billable: boolean;
projectId?: string;
taskId?: string;
tagIds?: string[];
timeInterval: {
start: string;
end?: string;
duration?: string;
};
workspaceId: string;
isLocked: boolean;
}
export interface Project {
id: string;
name: string;
clientId?: string;
clientName?: string;
color?: string;
billable: boolean;
archived: boolean;
duration?: string;
workspaceId: string;
}
export interface Workspace {
id: string;
name: string;
hourlyRate?: { amount: number; currency: string };
memberships: Array<{ userId: string; membershipType: string; membershipStatus: string }>;
}
export interface User {
id: string;
email: string;
name: string;
activeWorkspace: string;
status: string;
}
export interface Task {
id: string;
name: string;
projectId: string;
assigneeIds?: string[];
status: string;
billable?: boolean;
}
export interface SummaryReport {
groupOne: Array<{
_id: string;
duration: number;
amount: number;
name: string;
children?: Array<{ _id: string; duration: number; amount: number; name: string }>;
}>;
totals: Array<{ totalTime: number; totalAmount: number; entriesCount: number }>;
}
export interface DetailedReport {
timeentries: Array<{
_id: string;
description: string;
userId: string;
userName: string;
projectId?: string;
projectName?: string;
timeInterval: { start: string; end: string; duration: string };
amount: number;
billable: boolean;
}>;
totals: Array<{ totalTime: number; totalAmount: number; entriesCount: number }>;
}
export class ClockifyClient {
private apiKey: string;
private defaultWorkspaceId?: string;
private cachedUser?: User;
private cachedDefaultWorkspace?: string;
constructor(config: ClockifyConfig) {
this.apiKey = config.apiKey;
this.defaultWorkspaceId = config.workspaceId;
}
// Get current user with caching
async getCachedUser(): Promise<User> {
if (!this.cachedUser) {
this.cachedUser = await this.getCurrentUser();
}
return this.cachedUser;
}
// Get default workspace (from config, cache, or user's active workspace)
async getDefaultWorkspaceId(): Promise<string> {
if (this.defaultWorkspaceId) return this.defaultWorkspaceId;
if (this.cachedDefaultWorkspace) return this.cachedDefaultWorkspace;
const user = await this.getCachedUser();
this.cachedDefaultWorkspace = user.activeWorkspace;
return this.cachedDefaultWorkspace;
}
private async request<T>(
endpoint: string,
options: RequestInit = {},
useReportsApi = false
): Promise<T> {
const baseUrl = useReportsApi ? REPORTS_API_BASE : API_BASE;
const url = `${baseUrl}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'X-Api-Key': this.apiKey,
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
const errorText = await response.text();
// Provide friendlier error messages for common cases
const friendlyError = this.translateError(response.status, errorText, endpoint);
throw new Error(friendlyError);
}
// Handle empty responses
const text = await response.text();
if (!text) return {} as T;
return JSON.parse(text) as T;
}
private translateError(status: number, errorText: string, endpoint: string): string {
// Parse error response if it's JSON
let errorMessage = errorText;
try {
const parsed = JSON.parse(errorText);
errorMessage = parsed.message || parsed.error || errorText;
} catch {
// Keep original text
}
// Check for common patterns
if (status === 404) {
if (endpoint.includes('/workspaces/') && endpoint.split('/workspaces/')[1]?.split('/')[0]) {
const wsId = endpoint.split('/workspaces/')[1].split('/')[0];
if (endpoint.includes('/projects/')) {
return `Project not found. Use get_projects to list available projects.`;
}
if (endpoint.includes('/time-entries/')) {
return `Time entry not found.`;
}
if (endpoint.includes('/tags/')) {
return `Tag not found. Use get_tags to list available tags.`;
}
// Generic workspace resource not found
if (errorMessage.includes('Entity with identity')) {
return `Workspace "${wsId}" not found. Use get_workspaces to list available workspaces.`;
}
}
return `Resource not found: ${errorMessage}`;
}
if (status === 401) {
return `Authentication failed. Check your API key.`;
}
if (status === 403) {
return `Access denied. You may not have permission for this workspace or resource.`;
}
if (status === 400) {
// Check for "doesn't belong to" errors (wrong ID for workspace)
if (errorMessage.includes("doesn't belong to") || errorMessage.includes('does not belong to')) {
if (endpoint.includes('/time-entries')) {
return `Time entry not found in this workspace. Check the time entry ID.`;
}
return `Resource not found in this workspace. Check the ID.`;
}
return `Invalid request: ${errorMessage}`;
}
return `Clockify API error (${status}): ${errorMessage}`;
}
// ═══════════════════════════════════════════════════════════
// WORKSPACE OPERATIONS
// ═══════════════════════════════════════════════════════════
async getWorkspaces(): Promise<Workspace[]> {
return this.request<Workspace[]>('/workspaces');
}
async getCurrentUser(): Promise<User> {
return this.request<User>('/user');
}
async getWorkspaceUsers(workspaceId: string): Promise<User[]> {
return this.request<User[]>(`/workspaces/${workspaceId}/users`);
}
// ═══════════════════════════════════════════════════════════
// PROJECT OPERATIONS
// ═══════════════════════════════════════════════════════════
async getProjects(
workspaceId: string,
options?: { archived?: boolean; page?: number; pageSize?: number }
): Promise<Project[]> {
const params = new URLSearchParams();
if (options?.archived !== undefined) params.set('archived', String(options.archived));
if (options?.page) params.set('page', String(options.page));
if (options?.pageSize) params.set('page-size', String(options.pageSize));
const query = params.toString() ? `?${params.toString()}` : '';
return this.request<Project[]>(`/workspaces/${workspaceId}/projects${query}`);
}
async createProject(
workspaceId: string,
data: { name: string; clientId?: string; color?: string; billable?: boolean; isPublic?: boolean }
): Promise<Project> {
return this.request<Project>(`/workspaces/${workspaceId}/projects`, {
method: 'POST',
body: JSON.stringify(data),
});
}
async getProjectTasks(workspaceId: string, projectId: string): Promise<Task[]> {
return this.request<Task[]>(`/workspaces/${workspaceId}/projects/${projectId}/tasks`);
}
async createTask(
workspaceId: string,
projectId: string,
data: { name: string; assigneeIds?: string[]; status?: string; billable?: boolean }
): Promise<Task> {
return this.request<Task>(`/workspaces/${workspaceId}/projects/${projectId}/tasks`, {
method: 'POST',
body: JSON.stringify(data),
});
}
// ═══════════════════════════════════════════════════════════
// TIME ENTRY OPERATIONS
// ═══════════════════════════════════════════════════════════
async getTimeEntries(
workspaceId: string,
userId: string,
options?: {
start?: string;
end?: string;
project?: string;
page?: number;
pageSize?: number;
}
): Promise<TimeEntry[]> {
const params = new URLSearchParams();
if (options?.start) params.set('start', options.start);
if (options?.end) params.set('end', options.end);
if (options?.project) params.set('project', options.project);
if (options?.page) params.set('page', String(options.page));
if (options?.pageSize) params.set('page-size', String(options.pageSize));
const query = params.toString() ? `?${params.toString()}` : '';
return this.request<TimeEntry[]>(
`/workspaces/${workspaceId}/user/${userId}/time-entries${query}`
);
}
async createTimeEntry(
workspaceId: string,
data: {
start: string;
end?: string;
description?: string;
projectId?: string;
taskId?: string;
tagIds?: string[];
billable?: boolean;
}
): Promise<TimeEntry> {
return this.request<TimeEntry>(`/workspaces/${workspaceId}/time-entries`, {
method: 'POST',
body: JSON.stringify(data),
});
}
async stopTimer(workspaceId: string, userId: string, endTime?: string): Promise<TimeEntry> {
// First check if there's a running timer
const running = await this.getRunningTimer(workspaceId, userId);
if (!running) {
throw new Error('No timer currently running');
}
// PATCH to collection endpoint automatically stops the running timer
return this.request<TimeEntry>(`/workspaces/${workspaceId}/user/${userId}/time-entries`, {
method: 'PATCH',
body: JSON.stringify({ end: endTime || new Date().toISOString() }),
});
}
async deleteTimeEntry(workspaceId: string, timeEntryId: string): Promise<void> {
await this.request<void>(`/workspaces/${workspaceId}/time-entries/${timeEntryId}`, {
method: 'DELETE',
});
}
async updateTimeEntry(
workspaceId: string,
timeEntryId: string,
data: {
start?: string;
end?: string;
description?: string;
projectId?: string;
taskId?: string;
tagIds?: string[];
billable?: boolean;
}
): Promise<TimeEntry> {
return this.request<TimeEntry>(`/workspaces/${workspaceId}/time-entries/${timeEntryId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
// ═══════════════════════════════════════════════════════════
// REPORTING OPERATIONS (uses reports.api.clockify.me)
// ═══════════════════════════════════════════════════════════
async getSummaryReport(
workspaceId: string,
data: {
dateRangeStart: string;
dateRangeEnd: string;
summaryFilter?: { groups: string[] };
}
): Promise<SummaryReport> {
return this.request<SummaryReport>(
`/workspaces/${workspaceId}/reports/summary`,
{
method: 'POST',
body: JSON.stringify({
...data,
summaryFilter: data.summaryFilter || { groups: ['PROJECT', 'USER'] },
exportType: 'JSON',
}),
},
true // useReportsApi
);
}
async getDetailedReport(
workspaceId: string,
data: {
dateRangeStart: string;
dateRangeEnd: string;
userIds?: string[];
projectIds?: string[];
page?: number;
pageSize?: number;
}
): Promise<DetailedReport> {
const body: Record<string, unknown> = {
dateRangeStart: data.dateRangeStart,
dateRangeEnd: data.dateRangeEnd,
exportType: 'JSON',
detailedFilter: {
page: data.page || 1,
pageSize: data.pageSize || 50,
sortColumn: 'DATE',
},
};
if (data.userIds?.length) {
body.users = { ids: data.userIds, contains: 'CONTAINS', status: 'ACTIVE' };
}
if (data.projectIds?.length) {
body.projects = { ids: data.projectIds, contains: 'CONTAINS' };
}
return this.request<DetailedReport>(
`/workspaces/${workspaceId}/reports/detailed`,
{ method: 'POST', body: JSON.stringify(body) },
true
);
}
// ═══════════════════════════════════════════════════════════
// TAG OPERATIONS
// ═══════════════════════════════════════════════════════════
async getTags(workspaceId: string): Promise<Array<{ id: string; name: string; workspaceId: string }>> {
return this.request(`/workspaces/${workspaceId}/tags`);
}
async createTag(workspaceId: string, name: string): Promise<{ id: string; name: string; workspaceId: string }> {
return this.request(`/workspaces/${workspaceId}/tags`, {
method: 'POST',
body: JSON.stringify({ name }),
});
}
// ═══════════════════════════════════════════════════════════
// CLIENT OPERATIONS
// ═══════════════════════════════════════════════════════════
async getClients(workspaceId: string): Promise<Array<{ id: string; name: string; workspaceId: string }>> {
return this.request(`/workspaces/${workspaceId}/clients`);
}
// Get the currently running timer for a user (if any)
async getRunningTimer(workspaceId: string, userId: string): Promise<TimeEntry | null> {
// Fetch recent entries - running timer might not be the most recent if entries were created after
const entries = await this.getTimeEntries(workspaceId, userId, { pageSize: 50 });
const running = entries.find((e) => !e.timeInterval.end);
return running || null;
}
}