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
| Harvest | Rize | Notes |
|---|---|---|
| Account | Team or workspace | Choose the destination team before creating records. |
| Client | Client | Direct match. Rize clients can carry a default hourly rate. |
| Project | Project | Direct match. Rize projects belong to a client and team. |
| Task | Task | Harvest tasks are account-global; Rize tasks are project-scoped. Create one Rize task per project assignment. |
| Project or task assignment | Rate metadata | Reconcile per-project rates and budgets manually. |
| Time entry | Time entry | Harvest can store a date and duration; Rize requires an explicit start and end. |
| Project code | Project name, keywords, or prompt | Preserve the code if your team uses it for search or classification. |
| User | Team member | Match by email. Members must already belong to the Rize team. |
| Project budget | Budget | Map it where the shapes align; otherwise re-create it after migration. |
| Fixed-fee project or expense | No direct equivalent | Review 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
| Harvest | Rize | Transform |
|---|---|---|
spent_date plus started_time and ended_time | startTime, endTime | Combine them into ISO 8601 timestamps in the account timezone. Respect the account's 12-hour or 24-hour clock format. |
spent_date plus hours | startTime, endTime | For duration-only accounts, stack each day's entries sequentially from an agreed local start time. |
notes | title, description | Populating both usually produces the clearest Rize entry. |
project.id | projectId | Resolve through the ID map. |
(project.id, task.id) | taskId | Resolve through the project-scoped task map. |
| Project client | clientId | Derive from the mapped project. |
billable | billable | Pass through, while accounting for fixed-fee projects during reconciliation. |
id | idempotencyKey | Use a stable value such as harvest:<id>. |
is_running = true | Skip | Log 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
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:
- Staged entries minus deliberate skips against ID-map rows marked
created. - Total duration per month in Harvest and Rize.
- Per-project totals by week.
- 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
| Case | Handling |
|---|---|
| Running timer | Skip and log it. Let the user restart it in Rize. |
| Deleted project or task | Build maps from both catalog endpoints and nested objects on time entries. |
| Duration-only account | Stack each user's daily entries sequentially and document the synthetic start-time policy. |
| Overlapping ranges | Serialize writes per user and day, then compare source and destination counts. |
| Archived project | Create it for history, then archive it in Rize. |
| Duplicate project name | Disambiguate with the Harvest project code or client name before loading. |
| Multi-user account | Use identityId and teamId with the required permission, or import once per user with that user's key. |
| Fixed-fee project | Reconcile billable flags, rates, and revenue manually. |
Rollout checklist
- Migrate one month into a scratch workspace and reconcile it fully.
- Load clients, projects, and tasks into the real workspace.
- Have an account owner review the entity mapping.
- Backfill time entries oldest first, in monthly batches.
- Reconcile counts, durations, and project assignments.
- Dual-run briefly and use Harvest's
updated_sincefilter for a delta import. - 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.