Skip to main content

Migrating from Harvest to Rize

This guide covers moving clients, projects, tasks, and historical time entries from the Harvest API v2 into the Rize GraphQL API.

Use three separate phases: extract Harvest data into a local staging store, transform it offline into Rize's shape, then load it into Rize. Harvest's request quota makes this separation important: a failed write should not force you to fetch the source data again.

What moves

HarvestRizeNotes
AccountTeam 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.
TaskTaskHarvest tasks are account-global; Rize tasks are project-scoped. Create one Rize task per project assignment.
Project or task assignmentRate metadataReconcile per-project rates and budgets manually.
Time entryTime entryHarvest can store a date and duration; Rize requires an explicit start and end.
Project codeProject name, keywords, or promptPreserve the code if your team uses it for search or classification.
UserTeam memberMatch by email. Members must already belong to the Rize team.
Project budgetBudgetMap it where the shapes align; otherwise re-create it after migration.
Fixed-fee project or expenseNo direct equivalentReview manually.

Before writing a migration script, decide:

  • The date range to import.
  • Whether to migrate one user or the entire account.
  • Whether you will cut over immediately or run both systems temporarily.
  • How to convert duration-only entries into timestamps.

Call Harvest's company endpoint first and inspect wants_timestamp_timers. If it is false, the source does not contain original start and end timestamps.

Set up API access

Harvest

Create a personal access token in the Developers section of Harvest ID. Every request needs the token, the account ID, and a descriptive user agent.

curl "https://api.harvestapp.com/v2/company" \
-H "Authorization: Bearer $HARVEST_ACCESS_TOKEN" \
-H "Harvest-Account-Id: $HARVEST_ACCOUNT_ID" \
-H "User-Agent: YourCompany Migration (you@example.com)"

Capture wants_timestamp_timers, week_start_day, clock, and time_format; they affect time-entry conversion.

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 Harvest rate limits

Harvest currently limits general API requests to 100 per 15 seconds and Reports API requests to 100 per 15 minutes. An HTTP 429 response includes a Retry-After header. Pace requests proactively and wait for the specified interval after a 429. Review Harvest's current rate-limit documentation before a large migration.

Fetch workspace-wide where possible and cache metadata. Use the Reports API only for reconciliation because its rate-limit window is much tighter.

Phase 1: Extract from Harvest

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

Clients, projects, and assignments

GET /v2/clients
GET /v2/projects
GET /v2/projects/{project_id}/task_assignments
GET /v2/projects/{project_id}/user_assignments

Include archived and inactive records because historical entries can still reference them. For each project, retain at least its id, name, code, client ID, active and billable flags, billing method, hourly rate, and budget.

Tasks

GET /v2/tasks
GET /v2/projects/{project_id}/task_assignments

The task catalog is account-global. Project task assignments determine which tasks are used on each project and which rates apply. Record every (project_id, task_id) pair as a distinct candidate Rize task.

Time entries

GET /v2/time_entries?from=YYYY-MM-DD&to=YYYY-MM-DD&per_page=1000

Window extraction by month or quarter. Use hours, not rounded_hours. Use started_time and ended_time when present. Skip and log entries where is_running is true.

Harvest list responses contain a links object. Always follow links.next instead of constructing page URLs; this works with both page-based and cursor-based pagination.

url = "https://api.harvestapp.com/v2/time_entries"
params = {"from": from_date, "to": to_date, "per_page": 1000}

while url:
response = session.get(url, params=params, headers=headers)
respect_rate_limits(response)
data = response.json()
write_jsonl(data["time_entries"])

url = data["links"]["next"]
params = None

See Harvest's pagination documentation for the current response shape.

Phase 2: Transform offline

Create load-ready records and a durable ID map. Because one Harvest task can become several Rize tasks, include the project ID in the source key:

CREATE TABLE id_map (
entity TEXT NOT NULL,
harvest_id INTEGER NOT NULL,
harvest_parent_id INTEGER NOT NULL DEFAULT 0,
rize_id TEXT,
status TEXT NOT NULL,
error TEXT,
PRIMARY KEY (entity, harvest_id, harvest_parent_id)
);

Use harvest_parent_id = 0 for clients, projects, and time entries. For tasks, store the Harvest project ID in harvest_parent_id. Use statuses such as pending, created, skipped, and failed.

Normalize tasks

Create one Rize task for every Harvest (project, task) assignment. Resolve a time entry's task through both IDs, not through task.id alone. Disambiguate names with a project code or client prefix when that will make the resulting task list clearer.

Map time-entry fields

HarvestRizeTransform
spent_date plus started_time and ended_timestartTime, endTimeCombine them into ISO 8601 timestamps in the account timezone. Respect the account's 12-hour or 24-hour clock format.
spent_date plus hoursstartTime, endTimeFor duration-only accounts, stack each day's entries sequentially from an agreed local start time.
notestitle, descriptionPopulating both usually produces the clearest Rize entry.
project.idprojectIdResolve through the ID map.
(project.id, task.id)taskIdResolve through the project-scoped task map.
Project clientclientIdDerive from the mapped project.
billablebillablePass through, while accounting for fixed-fee projects during reconciliation.
ididempotencyKeyUse a stable value such as harvest:<id>.
is_running = trueSkipLog it and let the user restart the timer in Rize.

For a duration-only account, sort each user's entries for a day deterministically, for example by Harvest ID. Start the first at an agreed time such as 9:00 AM and begin each later entry where the previous one ended. Do not give every entry the same start time: overlapping ranges can be merged by Rize.

Document this timestamp policy for the account owner because the original start times cannot be recovered.

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, 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. Partition the work queue by user and day so two workers do not import adjacent or overlapping entries concurrently.

There is no bulk-create mutation. Keep concurrency modest, checkpoint after every batch, and retry transient server errors with exponential backoff and jitter. Log the GraphQL query, variables, and full error response without logging API keys. See GraphQL errors for the error-envelope format.

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.

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 Harvest and Rize.
  3. Per-project totals by week.
  4. Entries spanning midnight or daylight-saving transitions, entries without projects or tasks, high-volume duration-only days, and the oldest and newest entries.

Harvest time reports require from and to, and the reporting window cannot exceed 365 days. Monthly windows work well and stay within the tighter Reports API quota.

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 }
}
}

Edge cases

CaseHandling
Running timerSkip and log it. Let the user restart it in Rize.
Deleted project or taskBuild maps from both catalog endpoints and nested objects on time entries.
Duration-only accountStack each user's daily entries sequentially and document the synthetic start-time policy.
Overlapping rangesSerialize writes per user and day, then compare source and destination counts.
Archived projectCreate it for history, then archive it in Rize.
Duplicate project nameDisambiguate with the Harvest project code or client name before loading.
Multi-user accountUse identityId and teamId with the required permission, or import once per user with that user's key.
Fixed-fee projectReconcile billable flags, rates, and revenue manually.

Rollout checklist

  1. Migrate one month into a scratch workspace and reconcile it fully.
  2. Load clients, projects, and tasks into the real workspace.
  3. Have an account owner review the entity mapping.
  4. Backfill time entries oldest first, in monthly batches.
  5. Reconcile counts, durations, and project assignments.
  6. Dual-run briefly and use Harvest's updated_since filter for a delta import.
  7. Stop tracking new time in Harvest and complete the cutover.

For a one-person migration with only a few hundred entries, export a Harvest time-report CSV and use the Rize MCP server to create the records. Pass a stable idempotency_key for every time entry and preserve the same non-overlapping timestamp policy.