Modern, type-safe TypeScript client for the Intervals.icu API.
- Why This Library?
- Install
- Quick Start
- API Coverage
- Configuration
- Examples
- Error Handling
- Type Safety & Validation
- Contributing
- Development
- License
- Acknowledgments
- 🛡️ Runtime Validation - Catches API changes before they break your app (powered by Valibot)
- 🌍 Universal - Works in Node.js 18+, Bun, Deno, browsers, and edge runtimes
- 🪶 Lightweight - Zero heavy dependencies (~12KB gzipped)
- 🔐 Auth Ready - Supports both API key and OAuth2 authentication
- ⚡ Smart Rate Limiting - Automatic retry with exponential backoff
- 📦 Extensive Coverage - 64/146 API endpoints (44%): Activities, Events, Athletes, Wellness, Library, Chats
- 🎯 Type Safe - Full TypeScript support with strict types
- 🧪 Well Tested - 125 tests covering all operations
npm install @kuranov/intervals-client
# or
pnpm add @kuranov/intervals-client
# or
yarn add @kuranov/intervals-clientPerfect for personal automation scripts and server-side integrations.
import { IntervalsClient } from '@kuranov/intervals-client';
const client = new IntervalsClient({
auth: { type: 'apiKey', apiKey: process.env.INTERVALS_API_KEY! },
});
// List recent activities
const result = await client.activities.list("i12345", {
oldest: '2024-01-01',
limit: 10,
});
if (!result.ok) {
console.error('Error:', result.error.kind);
process.exitCode = 1;
} else {
console.log(`Found ${result.value.length} activities`);
result.value.forEach(activity => {
console.log(`${activity.startDateLocal}: ${activity.name}`);
});
}Required for applications serving multiple users. The library handles token management and refresh.
import { IntervalsClient } from '@kuranov/intervals-client';
const client = new IntervalsClient({
auth: {
type: 'accessToken',
accessToken: user.intervalsAccessToken,
},
});
// Get athlete profile
const athlete = await client.athletes.get('me');
if (athlete.ok) {
console.log(`Athlete: ${athlete.value.name}`);
console.log(`FTP: ${athlete.value.ftp || 'Not set'}`);
}Note: OAuth2 flow requires separate handling of authorization URL generation and token exchange. See the OAuth Guide below for complete implementation.
v1.1.0 provides 44% coverage (64/146 endpoints) of the Intervals.icu API with complete support for core resources:
- Activities (39/39 endpoints, 100%) - List, get, update, delete activities, intervals, streams, power curves, search
- Events (17/17 endpoints, 100%) - Manage calendar events, workouts, plans, downloads
- Chats (8/8 endpoints, 100%) - Messages, activity comments, notifications
- Athletes (~10 endpoints) - Get athlete profiles, settings, and summaries
- Wellness (~6 endpoints) - Track daily wellness metrics (HRV, sleep, mood, etc.)
- Library (~10 endpoints) - Manage workout library folders and plans
// Activities - Basic operations
await client.activities.list(athleteId, { oldest: '2024-01-01' });
await client.activities.get(activityId);
await client.activities.update(activityId, { name: 'Morning Ride' });
await client.activities.delete(activityId);
// Activities - Streams & analysis (NEW in v1.1.0)
await client.activities.getStreams(activityId, { types: ['watts', 'heartrate'] });
await client.activities.getPowerCurve(activityId);
await client.activities.search(athleteId, 'race');
await client.activities.downloadFitFile(activityId);
// Events (Calendar)
await client.events.list(athleteId, { oldest: '2024-01-01' });
await client.events.create(athleteId, { name: 'Workout', startDateLocal: '2024-01-15' });
await client.events.update(athleteId, eventId, { description: 'Updated' });
await client.events.downloadWorkout(athleteId, eventId, 'fit'); // NEW
await client.events.applyPlan(athleteId, { folderId: 123, oldest: '2024-01-01', newest: '2024-12-31' }); // NEW
// Chats - Messages & comments (NEW in v1.1.0)
await client.chats.list(athleteId);
await client.chats.sendMessage({ chatId: 123, text: 'Great workout!' });
await client.chats.listActivityMessages(activityId);
await client.chats.addActivityMessage(activityId, 'Nice effort!');
// Wellness
await client.wellness.list(athleteId, { oldest: '2024-01-01' });
await client.wellness.update(athleteId, '2024-01-15', { weight: 72, restingHR: 45 });
// Athletes
await client.athletes.get(athleteId);
await client.athletes.update(athleteId, { name: 'New Name' });
await client.athletes.getSettings(athleteId);
// Library
await client.library.listWorkouts(athleteId);
await client.library.listFolders(athleteId);
await client.library.createFolder(athleteId, { type: 'FOLDER', name: 'My Workouts' });See Examples below for complete usage patterns.
Automatically retries rate-limited requests (429) with exponential backoff and optional jitter:
const client = new IntervalsClient({
auth: { type: 'apiKey', apiKey: process.env.INTERVALS_API_KEY! },
retry: {
limit: 3, // Max retries (default: 3)
initialDelayMs: 1000, // Base delay (default: 1000ms)
maxDelayMs: 8000, // Max delay cap (default: 8000ms)
jitter: true, // Add randomness (default: true)
jitterFactor: 0.2, // ±20% variation (default: 0.2)
},
});Jitter prevents "thundering herd" when many clients retry simultaneously. The library automatically respects Retry-After headers when present.
Add logging, metrics, or monitoring with lifecycle hooks:
const client = new IntervalsClient({
auth: { type: 'apiKey', apiKey: process.env.INTERVALS_API_KEY! },
hooks: {
onRequest: ({ method, path }) => {
console.log(`→ ${method} ${path}`);
},
onResponse: ({ method, path, status, durationMs }) => {
console.log(`← ${method} ${path} ${status} (${durationMs}ms)`);
},
onError: ({ method, path, error, durationMs }) => {
console.error(`✗ ${method} ${path} failed after ${durationMs}ms:`, error);
},
onRetry: ({ method, path, attempt, maxAttempts, delayMs, reason }) => {
console.log(`⟳ ${method} ${path} retry ${attempt}/${maxAttempts} after ${delayMs}ms (${reason})`);
},
},
});All hooks support both sync and async functions. Note: If a hook throws an error, it will fail the request (except onError, which is swallowed to avoid masking the original error).
// Update an activity
const updated = await client.activities.update(123456, {
name: 'Morning Run',
description: 'Easy recovery run',
});
// Delete an activity
const deleted = await client.activities.delete(123456);
// Get intervals for an activity
const intervals = await client.activities.getIntervals(123456);
// Update intervals
const updatedIntervals = await client.activities.updateIntervals(123456, [
{ type: 'ACTIVE', start: 0, end: 300, averageWatts: 250 },
{ type: 'REST', start: 300, end: 420, averageWatts: 100 },
]);
// Split an interval at a specific point
const split = await client.activities.splitInterval(123456, 150);// List events (planned workouts, notes, etc.)
const events = await client.events.list("i12345", {
oldest: '2024-01-01',
newest: '2024-01-31',
category: ['WORKOUT', 'NOTES'],
});
// Get a single event
const event = await client.events.get("i12345", 789);
// Create an event
const newEvent = await client.events.create("i12345", {
name: 'Morning Workout',
category: 'WORKOUT',
start_date_local: '2024-01-15',
description: '5x5min @ FTP',
});
// Update an event
const updated = await client.events.update("i12345", 789, {
name: 'Updated Workout Name',
});
// Delete an event
const deleted = await client.events.delete("i12345", 789);
// Bulk create events
const bulkEvents = await client.events.createMultiple("i12345", [
{ name: 'Workout 1', start_date_local: '2024-01-15', category: 'WORKOUT' },
{ name: 'Workout 2', start_date_local: '2024-01-16', category: 'WORKOUT' },
]);
// Bulk delete events
const bulkDeleted = await client.events.deleteBulk("i12345", [
{ id: 100 },
{ id: 101 },
{ external_id: 'external-123' },
]);
// Update multiple events in a date range
const updatedRange = await client.events.updateMultiple(
"i12345",
{ hide_from_athlete: true },
{ oldest: '2024-01-01', newest: '2024-01-31' }
);
// List event tags
const tags = await client.events.listTags("i12345");// Get athlete with sport settings
const athlete = await client.athletes.get("i12345");
if (athlete.ok) {
console.log(athlete.value.name);
console.log(athlete.value.sportSettings);
}
// Update athlete
const updated = await client.athletes.update("i12345", {
name: 'Updated Name',
weight: 72,
timezone: 'America/New_York',
});
// Get athlete settings for a device
const desktopSettings = await client.athletes.getSettings("i12345", 'desktop');
const phoneSettings = await client.athletes.getSettings("i12345", 'phone');
const tabletSettings = await client.athletes.getSettings("i12345", 'tablet');
// Get athlete profile (includes FTP, thresholds, etc.)
const profile = await client.athletes.getProfile("i12345");
if (profile.ok) {
console.log(`FTP: ${profile.value.ftp}`);
console.log(`Max HR: ${profile.value.maxHeartrate}`);
console.log(`Threshold Power: ${profile.value.thresholdPower}`);
}
// Get athlete summary (CTL, ATL, TSB, etc.)
const summary = await client.athletes.getSummary("i12345", {
start: '2024-01-01',
end: '2024-01-31',
tags: ['cycling', 'running'],
});
if (summary.ok) {
summary.value.forEach((s) => {
console.log(`${s.startDateLocal}: CTL=${s.ctl}, ATL=${s.atl}, TSB=${s.tsb}`);
});
}// List wellness records for a date range
const records = await client.wellness.list("i12345", {
oldest: '2024-01-01',
newest: '2024-01-31',
});
if (records.ok) {
records.value.forEach((w) => {
console.log(`${w.id}: weight=${w.weight}kg, HR=${w.restingHR}`);
});
}
// Get wellness for a specific date
const wellness = await client.wellness.get("i12345", '2024-01-15');
if (wellness.ok) {
console.log(`Weight: ${wellness.value.weight}kg`);
console.log(`Resting HR: ${wellness.value.restingHR}bpm`);
console.log(`HRV: ${wellness.value.hrv}`);
console.log(`Sleep: ${wellness.value.sleepSecs}s`);
}
// Update wellness for a date
const updated = await client.wellness.update("i12345", '2024-01-15', {
weight: 71.5,
sleepSecs: 28800, // 8 hours
soreness: 3,
fatigue: 2,
mood: 8,
comments: 'Feeling good today',
});
// Bulk update multiple wellness records
const bulkUpdate = await client.wellness.updateBulk("i12345", [
{ id: '2024-01-15', weight: 70.5, restingHR: 55 },
{ id: '2024-01-16', weight: 70.3, restingHR: 54 },
{ id: '2024-01-17', weight: 70.1, restingHR: 53 },
]);For public applications serving multiple users, implement the full OAuth2 authorization code flow:
// Step 1: Redirect user to authorization URL
const authUrl = new URL('https://intervals.icu/oauth/authorize');
authUrl.searchParams.set('client_id', YOUR_CLIENT_ID);
authUrl.searchParams.set('redirect_uri', YOUR_REDIRECT_URI);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'ACTIVITY:READ ACTIVITY:WRITE WELLNESS:READ');
authUrl.searchParams.set('state', generateRandomState()); // CSRF protection
// Redirect user to: authUrl.toString()
// Step 2: Handle callback and exchange code for token
const tokenResponse = await fetch('https://intervals.icu/api/v1/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
code: codeFromCallback,
client_id: YOUR_CLIENT_ID,
client_secret: YOUR_CLIENT_SECRET,
redirect_uri: YOUR_REDIRECT_URI,
}),
});
const tokens = await tokenResponse.json();
// { access_token, refresh_token, expires_in, token_type, scope }
// Step 3: Use the access token
const client = new IntervalsClient({
auth: { type: 'accessToken', accessToken: tokens.access_token },
});
// Step 4: Refresh when expired (tokens expire after 8 hours)
const refreshResponse = await fetch('https://intervals.icu/api/v1/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: tokens.refresh_token,
client_id: YOUR_CLIENT_ID,
client_secret: YOUR_CLIENT_SECRET,
}),
});
const newTokens = await refreshResponse.json();
// Update stored tokens and recreate client with new access_tokenAvailable OAuth scopes:
ACTIVITY:READ,ACTIVITY:WRITEWELLNESS:READ,WELLNESS:WRITECALENDAR:READ,CALENDAR:WRITESETTINGS:READ
// List all workouts in your library
const workouts = await client.library.listWorkouts("i12345");
if (workouts.ok) {
workouts.value.forEach((w) => {
console.log(`${w.name} (${w.activityType})`);
});
}
// Get a specific workout
const workout = await client.library.getWorkout("i12345", 123);
if (workout.ok) {
console.log(`Workout: ${workout.value.name}`);
console.log(`Description: ${workout.value.description}`);
}
// Create a new workout
const newWorkout = await client.library.createWorkout("i12345", {
name: '5x5min @ FTP',
description: '5 x 5min @ FTP with 3min recovery',
folder_id: 10,
activity_type: 'Ride',
tags: ['intervals', 'ftp'],
});
// Update a workout
const updated = await client.library.updateWorkout("i12345", 123, {
name: 'Updated Workout Name',
description: 'New description',
});
// Delete a workout
const deleted = await client.library.deleteWorkout("i12345", 123);
// Create multiple workouts at once
const bulkWorkouts = await client.library.createMultipleWorkouts("i12345", [
{ name: 'Workout 1', folder_id: 10, activity_type: 'Ride' },
{ name: 'Workout 2', folder_id: 10, activity_type: 'Ride' },
]);
// List folders and plans
const folders = await client.library.listFolders("i12345");
if (folders.ok) {
folders.value.forEach((f) => {
console.log(`${f.name} (${f.type})`);
console.log(` Workouts: ${f.children?.length || 0}`);
});
}
// Create a folder
const newFolder = await client.library.createFolder("i12345", {
type: 'FOLDER',
name: 'Cycling Workouts',
description: 'My cycling library',
visibility: 'PRIVATE',
});
// Create a training plan
const newPlan = await client.library.createFolder("i12345", {
type: 'PLAN',
name: 'Base Building Plan',
description: '12-week aerobic base',
visibility: 'PUBLIC',
rollout_weeks: 12,
auto_rollout_day: 1, // Monday
starting_ctl: 50,
starting_atl: 40,
});
// Update a folder
const updatedFolder = await client.library.updateFolder("i12345", 10, {
name: 'Updated Folder Name',
description: 'New description',
});
// Delete a folder
const deletedFolder = await client.library.deleteFolder("i12345", 10);
// List all workout tags
const tags = await client.library.listTags("i12345");
if (tags.ok) {
console.log('Tags:', tags.value.join(', '));
}All API calls return a Result<T, ApiError> - no exceptions are thrown.
const result = await client.activities.get(123);
if (!result.ok) {
// Error handling
switch (result.error.kind) {
case 'Unauthorized':
console.error('Invalid credentials');
break;
case 'NotFound':
console.error('Activity not found');
break;
case 'Schema':
console.error('Validation failed:', result.error.issues);
break;
case 'RateLimit':
console.error('Rate limited, retry after:', result.error.retryAfter);
break;
default:
console.error('Unknown error:', result.error);
}
} else {
// Success - use result.value
console.log(result.value.name);
}Unauthorized(401) - Invalid API key or expired tokenForbidden(403) - Insufficient permissionsNotFound(404) - Resource doesn't existRateLimit(429) - Too many requests (includesretryAfterhint)Schema- Response validation failed (API changed or malformed data)Network- Connection error, timeout, or DNS failureUnknown- Other HTTP errors
This library uses Valibot for runtime schema validation and TypeScript type generation.
The library uses a "loose object" validation strategy that balances type safety with API flexibility:
// Schema example (simplified)
export const ActivitySchema = v.looseObject({
// Required fields - API always returns these
id: v.union([v.string(), v.number()]),
type: v.string(),
startDateLocal: v.string(),
// Optional fields - may or may not be present
name: v.optional(v.string()),
distance: v.optional(v.number()),
// ... 100+ more optional fields
});Reason 1: API Evolution
The Intervals.icu API can return extra fields not documented in the OpenAPI spec. Using looseObject allows the library to accept these fields without breaking.
Reason 2: Incomplete Documentation Some API endpoints return fields that aren't in the official spec. Strict validation would reject valid API responses.
Reason 3: Future Compatibility When Intervals.icu adds new fields, your code continues working without requiring a library update.
✅ Required fields are enforced:
// TypeScript knows these are always present
activity.id // number | string (never undefined)
activity.type // string (never undefined)
activity.startDateLocal // string (never undefined)✅ Optional fields are typed correctly:
// TypeScript knows these might be undefined
activity.name // string | undefined
activity.distance // number | undefined✅ Invalid data is caught at runtime:
const result = await client.activities.get(123);
if (!result.ok && result.error.kind === 'Schema') {
// API returned data that doesn't match the schema
console.error('Validation failed:', result.error.issues);
}❌ Extra fields are allowed (not validated):
// If API returns a new field "future_field", it passes validation
// and is available on the object (but TypeScript doesn't know about it)Benefits:
- ✅ Forward compatible with API changes
- ✅ Works with undocumented API fields
- ✅ No false-positive validation errors
- ✅ Simple, consistent pattern across all schemas
Limitations:
⚠️ Extra fields aren't typed in TypeScript (use type assertions if needed)⚠️ Can't catch typos in field names at runtime (TypeScript catches them at compile time)
If the API returns data that doesn't match the schema (e.g., missing required field, wrong type), you'll get a Schema error:
const result = await client.activities.get(123);
if (!result.ok) {
if (result.error.kind === 'Schema') {
// Valibot validation failed
console.error('Schema validation error:');
result.error.issues.forEach(issue => {
console.error(` ${issue.path}: ${issue.message}`);
});
}
}This is rare but can happen if:
- The Intervals.icu API changes in a breaking way
- There's a bug in the API
- Network corruption (very rare)
Contributions are welcome! Please:
- Open an issue to discuss the change
- Fork the repository
- Create a feature branch
- Add tests for new functionality
- Ensure all tests pass (
pnpm test) - Submit a pull request
# Install dependencies
pnpm install
# Type check
pnpm run typecheck
# Run tests
pnpm test
# Build
pnpm run buildMIT © Anton Kuranov
Built for the Intervals.icu community. Special thanks to David Tinker for creating and maintaining the platform and its open API.