Skip to main content

Migrating from Toggl Track to Rize

This guide covers moving clients, projects, tasks, tags, and historical time entries from the Toggl Track API into the Rize GraphQL API.

Use three separate phases: extract Toggl data into a local staging store, transform it offline into Rize's shape, then load it into Rize. Keeping the phases separate makes the migration resumable and avoids re-fetching data when a write fails.

What moves

Toggl TrackRizeNotes
WorkspaceTeam or workspaceChoose the destination team before creating records.
ClientClientDirect match. Rize clients can carry a default hourly rate.
ProjectProjectDirect match. Rize projects belong to a client and team.
TaskTaskDirect match. Tasks belong to projects in both systems.
TagLabel, project keyword, or description textTags require triage because Rize labels are classification rules, not free-form tags.
Time entryTime entryToggl stores a start and duration; Rize requires an explicit start and end.
Project rate and currencyClient or team-member hourly rateReconcile rates manually because the systems attach them to different entities.
estimated_hoursBudgetRe-create as a Rize budget after migration.
UserTeam memberMatch by email. Members must already belong to the Rize team.

Before writing a migration script, decide:

  • How much history to move. Use the Reports API for older history.
  • Whether to migrate one user or the entire workspace.
  • Whether you will cut over immediately or run both systems temporarily.
  • Which Rize team will own the imported records.

Set up API access

Toggl Track

Toggl uses HTTP Basic authentication. Pass your API token as the username and the literal string api_token as the password. Get the token from Profile settings in Toggl.

curl -u "$TOGGL_API_TOKEN:api_token" \
-H "Content-Type: application/json" \
"https://api.track.toggl.com/api/v9/me?with_related_data=true"

The response includes default_workspace_id and the workspaces, clients, and projects the token can access.

Rize

Generate an API key from Settings > API Keys. See Authenticate with the GraphQL API for setup details. Rize accepts bearer tokens at a single GraphQL endpoint:

curl -X POST https://api.rize.io/api/v1/graphql \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $RIZE_API_KEY" \
-d '{"query":"query CurrentUser { currentUser { email } }"}'

Use the GraphQL explorer to inspect current input types before running a production migration.

Respect Toggl quotas

Toggl applies plan-dependent hourly quotas and a separate rate limiter. Read X-Toggl-Quota-Remaining and X-Toggl-Quota-Resets-In on every response, pace requests proactively, and back off when the API returns an error. Quotas and endpoint behavior can change, so review Toggl's current API overview before a large migration.

The normal GET /api/v9/me/time_entries endpoint only returns recent history. Use the Reports API v3 detailed-search JSON endpoint for older data. JSON results retain time-entry IDs; CSV and XLSX exports may not.

Phase 1: Extract from Toggl

Write each raw response to JSONL files or a small SQLite database before transforming it.

Clients, projects, and tasks

GET /api/v9/workspaces/{workspace_id}/clients
GET /api/v9/workspaces/{workspace_id}/projects
GET /api/v9/workspaces/{workspace_id}/projects/paginated?start_project_id={last_seen_id}
GET /api/v9/workspaces/{workspace_id}/projects/{project_id}/tasks

Include archived and inactive projects because historical entries can still reference them. Task retrieval is per project, so cache each response.

Historical time entries

Request ungrouped, unrounded JSON results from the detailed reports endpoint:

POST https://api.track.toggl.com/reports/api/v3/workspace/{workspace_id}/search/time_entries
Content-Type: application/json
Authorization: Basic base64("<api_token>:api_token")
{
"start_date": "2021-01-01",
"end_date": "2021-03-31",
"grouped": false,
"rounding": 0,
"enrich_response": true,
"order_by": "date",
"order_dir": "ASC",
"page_size": 1000
}

Chunk the migration by month or quarter. Smaller windows are easier to retry and verify.

Detailed reports paginate through response headers. Pass X-Next-ID and X-Next-Row-Number back as first_id and first_row_number; stop when the headers are absent.

next_id, next_row = None, None

while True:
body = {**base_filters}
if next_id:
body["first_id"] = next_id
body["first_row_number"] = next_row

response = session.post(url, json=body, auth=(token, "api_token"))
respect_quota(response)
write_jsonl(response.json())

next_id = response.headers.get("X-Next-ID")
next_row = response.headers.get("X-Next-Row-Number")
if not next_id:
break

Phase 2: Transform offline

Create load-ready records and a durable ID map:

CREATE TABLE id_map (
entity TEXT NOT NULL,
toggl_id INTEGER NOT NULL,
rize_id TEXT,
status TEXT NOT NULL,
error TEXT,
PRIMARY KEY (entity, toggl_id)
);

Use statuses such as pending, created, skipped, and failed. Every load operation should read and update this table so a retry does not depend on row order.

Triage Toggl tags

Do not map all Toggl tags directly to Rize labels. Sort tags by usage and route each one based on meaning:

Tag typeDestinationExample
Broad, recurring work categoryRize label with an authored promptMeetings, code review, support
Client, project, repository, or workstream identifierProject keywords or promptAccount name, repository name, Jira prefix
One-off tag or personal shorthandAppend to the time-entry descriptionTemporary or low-volume context

Have the workspace owner approve the label set and prompts before loading it.

Map time-entry fields

TogglRizeTransform
startstartTimePass the RFC 3339 timestamp through.
stop or secondsendTimePrefer stop; otherwise calculate start + seconds.
descriptiontitle, descriptionPopulating both usually produces the clearest Rize entry.
project_idprojectIdResolve through the ID map.
task_idtaskIdResolve through the ID map; it can be null.
Project clientclientIdDerive from the mapped project.
billablebillablePass through.
ididempotencyKeyUse a stable value such as toggl:<id>.
Negative durationSkipA negative duration represents a running timer.

Phase 3: Load into Rize

Avoid overlapping imports

Rize can extend an existing active entry when a newly created entry overlaps its time range. The API may return successfully even though the entries were merged. Keep imported ranges non-overlapping and reconcile the destination row count before completing the migration.

Load records in dependency order: clients, projects, tasks, labels, then time entries. Save each new Rize ID before loading dependent records.

Entity mutations such as createProject nest their entity fields under input.args:

mutation CreateProject($name: String!, $clientId: ID) {
createProject(input: { args: { name: $name, clientId: $clientId } }) {
project { id name }
errors { message }
}
}

createTimeEntry is different: its fields sit directly on input.

mutation CreateTimeEntry(
$startTime: ISO8601DateTime!
$endTime: ISO8601DateTime!
$title: String
$description: String
$projectId: ID
$clientId: ID
$taskId: ID
$teamId: ID
$identityId: ID
$billable: Boolean
$idempotencyKey: String
) {
createTimeEntry(input: {
startTime: $startTime
endTime: $endTime
title: $title
description: $description
projectId: $projectId
clientId: $clientId
taskId: $taskId
teamId: $teamId
identityId: $identityId
billable: $billable
idempotencyKey: $idempotencyKey
}) {
timeEntry { id startTime endTime }
errors { message }
}
}

For multi-user imports, identityId requires teamId plus permission to edit that team member's time. Otherwise, use one API key per user. Keep concurrency modest, serialize writes for the same user and time range, checkpoint every batch, and retry transient server errors with exponential backoff and jitter.

The GraphQL API deduplicates matching idempotencyKey values created during the previous 24 hours. The ID map remains the durable protection against duplicates when a migration resumes later.

GraphQL failures use the standard Rize error envelope. See GraphQL errors before implementing retries.

Verify the migration

Do not rely on a successful loader exit alone. Compare:

  1. Staged entries minus deliberate skips against ID-map rows marked created.
  2. Total duration per month in Toggl and Rize.
  3. Per-project totals by week.
  4. Entries spanning midnight or daylight-saving transitions, entries without projects, and the oldest and newest entries.

Query Rize entries with cursor pagination. See GraphQL pagination for the complete pagination pattern:

query TimeEntries($first: Int, $after: String) {
timeEntries(first: $first, after: $after) {
edges {
node { id startTime endTime billable project { id name } }
cursor
}
pageInfo { hasNextPage endCursor }
}
}

Rollout checklist

  1. Migrate one week into a scratch workspace and reconcile it fully.
  2. Load clients, projects, tasks, and approved labels into the real workspace.
  3. Backfill time entries oldest first, in monthly batches.
  4. Reconcile counts, durations, and project assignments.
  5. Dual-run briefly and import entries updated since the initial backfill.
  6. Stop tracking new time in Toggl and complete the cutover.

For a one-person migration with only a few hundred entries, export a Toggl CSV and use the Rize MCP server to create the records. Pass a stable idempotency_key for every time entry.