How to Migrate from Harvest to Rize: A Complete Guide

jonathan wu · August 24, 2026 · 13 min read

Most teams switching from Harvest to Rize start tracking fresh the day they install the app. But if you need your historical clients, projects, and time entries in Rize, the migration is straightforward once you respect two constraints: Harvest's request quota, and Rize's expectation of explicit start and end timestamps for every entry.

This guide walks a three-phase migration: extract everything from Harvest into a local staging store, transform it offline into Rize's shape, then load it into Rize. The phases are deliberately decoupled so a failed write never forces you to burn Harvest quota re-fetching source data.

Switch without losing history

Try Rize free for 7 days and see how automatic time tracking captures the billable hours Harvest timers miss. No credit card required.

Start Free Trial

1. What moves, and what it becomes

| Harvest | Rize | Notes | |---|---|---| | Account | Team / workspace | One Rize team per Harvest account. Decide this first — it determines every team_id downstream. | | Client | Client | Direct match. Rize clients carry a default hourly rate. | | Project | Project | Direct match. Rize projects attach to a client and to a team. | | Task | Task | Harvest tasks are account-global and assigned to many projects; Rize tasks sit under a single project. Flatten assignments so each project gets its own copy. | | Project / task assignment | Rate metadata | No direct entity. Assignments carry per-project hourly rates and budgets; Rize hangs billing rates off the client and team members. | | Time entry | Time entry | Harvest stores spent_date + hours (and optional started_time / ended_time); Rize wants explicit start_time and end_time. | | Project code | — | No direct equivalent. Append to the project name or description if you need it for search. | | Fixed-fee project | — | No direct equivalent. Billable flag and rates may not apply the same way. | | Expense | — | Not migrated. | | User | Team member | Matched by email. Members must already exist in the Rize workspace. | | Project budget | Budget (optional) | Rize supports budgets; map budget / budget_by where the shapes align, otherwise re-express after migration. |

2. Decide the scope before you write any code

How far back? Time entries can be filtered with from and to dates. If you only need recent history the job is considerably simpler.

One user or the whole account? A multi-user migration needs matching Rize accounts and an admin-scoped Rize API key; entries created by a key are owned by that key's user unless attribution is supported.

Are you cutting over or dual-running? Dual-running for a week means you must handle a delta sync, which changes how you key idempotency. Harvest's updated_since filter on time_entries is the natural delta hook.

Duration or timestamps? Call GET /v2/company first and check wants_timestamp_timers. If it is false, every entry is duration-only and you must choose a policy for turning hours into start_time / end_time.

3. Credentials and endpoints

Harvest

Harvest uses a bearer token plus an account ID. Create a Personal Access Token in Harvest ID (Developers section). You will also see the account ID there.

Every request needs three headers:

Authorization: Bearer $HARVEST_ACCESS_TOKEN
Harvest-Account-Id: $HARVEST_ACCOUNT_ID
User-Agent: YourApp (you@example.com)

Confirm the token and read the account settings:

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

The response contains wants_timestamp_timers, week_start_day, clock, and time_format. Capture them — they affect how you parse and create time entries.

Rize

Rize uses a bearer token against a single GraphQL endpoint. Generate the key from Settings → API Keys.

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

An interactive playground lives at https://api.rize.io/api/v1/graphiql — keep it open while you build.

4. The constraint that shapes everything: Harvest's rate limits

Harvest applies two throttles:

| API | Limit | Reset | |---|---|---| | General API | 100 requests per 15 seconds | Per rolling 15-second window | | Reports API | 100 requests per 15 minutes | Per rolling 15-minute window |

Exceeding the limit returns HTTP 429. The Retry-After header gives the number of seconds to wait — this is the only rate-limit signal Harvest documents, so treat it as the contract. Some responses have been observed carrying X-RateLimit-style headers, but they are undocumented and the names are inconsistent across reports; do not build your throttling on them. Instead, pace requests yourself: run a client-side token bucket at a bit under the limit (~6 requests per second on the general API), and on any 429 sleep for Retry-After seconds before resuming. If rate-limit headers happen to be present, log them as diagnostics — nothing more. A migration that trips the throttle at hour three and restarts from zero is the failure mode to design against.

What this means in practice

A five-year backfill of ~40,000 time entries pulled at 2,000 rows per request is about 20 requests — comfortable on the general API. The expensive part is metadata: if you have 150 projects and fetch task assignments one project at a time, that is 150 requests — fine at ~6 requests per second. Fetch workspace-wide where an endpoint offers it, and cache aggressively. Use the Reports API only for verification; its 15-minute window is much tighter.

5. Phase one — extract from Harvest

Pull metadata first, then entries. Write every raw response to disk before you touch it. The staging store can be JSONL files or a small SQLite database; what matters is that you can re-run the transform and load steps without re-hitting Harvest.

5.1 Clients and projects

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

Capture archived and inactive projects and clients too — historical entries still reference them, and an entry whose project you skipped will import unassigned. Use is_active filters if you want to pull active and archived in separate passes.

For each project, keep at least id, name, code, client.id, is_active, is_billable, bill_by, hourly_rate, and budget.

5.2 Tasks

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

tasks returns the account-global task catalog. The per-project task_assignments endpoint tells you which tasks are actually used on each project and what rates apply. This is per-project and therefore the most quota-hungry metadata step, but the general API limit is generous. If a global task is assigned to many projects, record each (project_id, task_id) pair as a distinct candidate Rize task.

5.3 Time entries

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

The endpoint returns up to 2,000 entries per page by default; use a lower per_page for stability. Window by month or quarter rather than requesting five years in one call. Smaller windows are re-runnable, checkpointable, and far less likely to time out.

Representative shape:

{
  "id": 636709355,
  "spent_date": "2017-03-02",
  "user": { "id": 1782959, "name": "Kim Allen" },
  "client": { "id": 5735774, "name": "ABC Corp" },
  "project": { "id": 14307913, "name": "Marketing Website" },
  "task": { "id": 8083365, "name": "Graphic Design" },
  "hours": 2.11,
  "rounded_hours": 2.25,
  "notes": "Adding CSS styling",
  "is_running": false,
  "started_time": "3:00pm",
  "ended_time": "5:00pm",
  "billable": true,
  "billable_rate": 100.0,
  "cost_rate": 50.0
}

Always use hours, not rounded_hours, so you do not silently import rounded values. If started_time and ended_time are present, use them. If is_running is true, skip the entry and log it — let the user restart the timer in Rize.

5.4 Pagination

Responses include page, total_pages, total_entries, and a links block with first, next, previous, and last. Follow links.next — do not construct page URLs yourself. Harvest's docs are explicit about this, and several endpoints have moved to cursor-based pagination where the page parameter is deprecated and page / next_page come back null on all but the first and last pages. A loop that increments page=N manually will work on some endpoints today and silently misbehave on others. The links.next URL is correct in both schemes: it carries a cursor when the endpoint is cursor-paginated and a page number when it is not. Iterate until links.next is null (the final page also returns results normally).

url = "https://api.harvestapp.com/v2/time_entries"
params = {"from": from_date, "to": to_date, "per_page": 1000}
while url:
    r = session.get(url, params=params, headers=headers)
    respect_rate_limits(r)          # pace proactively; sleep Retry-After on 429
    data = r.json()
    write_jsonl(data["time_entries"])
    url = data["links"]["next"]     # full URL, already includes cursor/page + params
    params = None                   # links.next carries the query string itself

6. Phase two — transform

Do this entirely offline, against the staged files. The output is a set of load-ready records plus an ID map table — the single most important artefact in the whole migration.

6.1 The ID map

CREATE TABLE id_map (
  entity     TEXT NOT NULL,      -- 'client' | 'project' | 'task' | 'time_entry'
  harvest_id INTEGER NOT NULL,
  rize_id    TEXT,               -- NULL until successfully created
  status     TEXT NOT NULL,      -- 'pending' | 'created' | 'skipped' | 'failed'
  error      TEXT,
  PRIMARY KEY (entity, harvest_id)
);

Every load operation reads and writes this table. It is what makes the migration resumable and what lets you prove afterwards that nothing was dropped.

6.2 Task normalization

Harvest tasks are account-global; Rize tasks are project-scoped. A single Harvest task such as "Research" may be assigned to twenty projects. The transform step must therefore create one Rize task record for each (project, task) pair that actually appears, because that is how Rize's Client/Project/Task hierarchy stores it.

If the same task name means different things on different projects, disambiguate the Rize task name with the project code or client prefix before loading. If the same task name means the same thing everywhere, still create it per project; you can deduplicate later if Rize ever adds shared tasks.

6.3 Time-entry field mapping

| Harvest | Rize | Transform | |---|---|---| | spent_date + started_time / ended_time | start_time / end_time | Combine date and time into ISO 8601. Respect the account clock (12h / 24h). | | spent_date + hours | start_time / end_time | If wants_timestamp_timers is false, synthesize timestamps by stacking each day's entries sequentially from a default local start (e.g., 9:00 AM): sort the day's entries deterministically (by Harvest id), place the first at 9:00 AM, and start each subsequent entry where the previous one ended. Do not give every entry the same default start — Rize's entry creation may extend or update a matching active entry whose time range overlaps, so same-start entries can silently merge into one (see §9). Document the policy; the original start times are not recoverable. | | notes | title / description | Harvest has one text field; Rize has both. Populating both is usually the friendlier read. | | project.id | project_id | Resolve through the ID map. | | task.id | task_id | Resolve through the project-scoped task map; may be null. | | client.id | client_id | Derive from the project in case the entry's client is missing or changed. | | billable | billable | Pass through. If the project is fixed-fee, the billable flag may not represent revenue the same way in Rize. | | id | idempotency key | Use harvest:<id> so a retry cannot duplicate. | | is_running = true | — | Skip and log. | | rounded_hours | — | Do not use; import hours only. |

7. Phase three — load into Rize

Load in dependency order: clients → projects → tasks → time entries. Each step writes its new Rize IDs into the ID map before the next step reads them.

7.1 Creating a project

curl -X POST https://api.rize.io/api/v1/graphql \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $RIZE_API_KEY" \
  -d '{
    "query": "mutation CreateProject($name: String!, $clientId: ID) { createProject(input: { args: { name: $name, clientId: $clientId } }) { project { id name } } }",
    "variables": { "name": "Website Redesign", "clientId": "124112318" }
  }'

Note the nesting: Rize mutations take input: { args: { ... } } rather than a flat input object. Every mutation in the migration follows that shape. Verify createClient and createTask argument names in the GraphiQL schema explorer before you build against them.

7.2 Creating a time entry

Rize takes an explicit start and end rather than a duration:

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

Verify these argument names against the live schema. Open https://api.rize.io/api/v1/graphiql, find the input types for createTimeEntry, createClient, and createTask, and confirm whether an idempotencyKey argument is exposed. While you are there, also confirm how createTimeEntry handles an overlapping time range — Rize's MCP layer extends and updates a matching active entry that overlaps the requested range rather than creating a new one, and if the GraphQL mutation behaves the same way, non-overlapping synthesized timestamps (§6.3) are a correctness requirement, not a nicety. Five minutes there saves a debugging cycle later.

7.3 Throughput and failure handling

There is no documented bulk-create mutation — budget one round trip per entity. 40,000 entries at five writes a second is roughly two and a quarter hours.

Keep concurrency modest (four to eight workers) and back off on 5xx with jitter. If you parallelize entry creation, never let two workers write entries for the same user on the same day concurrently — with sequentially stacked timestamps, adjacent entries share boundaries, and racing writers make overlap behavior (§6.3) unpredictable. Partitioning the work queue by (user, spent_date) is the simple fix.

Checkpoint after every batch by updating the ID map. A crash should cost you one batch, not the run.

Rize returns errors in the standard GraphQL envelope with an extensions.code field. Log the full response body, the query, and the variables — that is exactly what Rize support will ask for.

{
  "errors": [
    {
      "message": "Error message details",
      "locations": [{ "line": 3, "column": 5 }],
      "path": ["createTimeEntry"],
      "extensions": { "code": "BAD_USER_INPUT" }
    }
  ]
}

8. Verification

Do not declare the migration done because the loader exited zero. Reconcile on numbers.

  1. Row count. Entries staged from Harvest, minus deliberate skips (running timers), should equal rows in the ID map with status created. If Rize merged any entries on overlap, the count of Rize entries may be lower than the ID-map count even when nothing was dropped — a mismatch here is your first signal that the timestamp-stacking policy leaked an overlap.
  2. Total duration. Query Harvest's time reports (/v2/reports/time/projects or /v2/reports/time/clients) per month and compare against the same window queried from Rize. Note that each time-report request requires both from and to, and the window cannot exceed 365 days — monthly windows are fine; a single five-year request is not. Any drift usually means rounded_hours was used during extraction or duration-only entries were merged or placed at the wrong synthesized start time.
  3. Per-project totals. A matching grand total can still hide entries attached to the wrong project. Compare project by project, week by week.
  4. Boundary spot-checks. Entries spanning midnight, entries crossing a DST transition, entries with no project or task, days with many duration-only entries (the stacking policy's worst case), and the oldest and newest entries in the set.
query TimeEntries($first: Int, $after: String) {
  timeEntries(first: $first, after: $after) {
    edges {
      node { id startTime endTime billable project { id name } }
      cursor
    }
    pageInfo { hasNextPage endCursor }
  }
}

Rize uses cursor pagination: page forward with first / after, reverse with last / before, and stop when pageInfo.hasNextPage is false.

9. Known edge cases

| Case | Handling | |---|---| | Running timer at extraction time | Harvest sets is_running: true. Skip it, log it, and let the user restart the timer in Rize. | | Entry references a deleted project or task | The entry still carries the nested project and task objects. Build the maps from both the catalog endpoints and the entries themselves. | | Duration-only account (wants_timestamp_timers: false) | Every entry is spent_date + hours. Stack each day's entries sequentially from a default start (§6.3); never reuse the same start time within a day. Document the policy; the original start times do not exist. | | Overlapping time ranges on create | Rize's entry creation (confirmed on the MCP layer; verify the GraphQL mutation in GraphiQL) may extend and update a matching active entry that overlaps the requested range instead of creating a new one. Overlapping imports can silently merge — no error, just fewer entries than you sent. The sequential-stacking policy plus per-(user, day) write serialization (§7.3) prevents this; the §8 row-count and duration reconciliation detects it. | | Archived Harvest projects | Create them, then set status to archived with updateProject so they do not clutter the active list. | | Duplicate project names | Rize's createProject behaviour may update an existing project of the same name. If Harvest has two projects with one name under different clients, disambiguate with the project code before loading. | | Multi-user accounts | Filter extraction with user_id and run one load pass per user with that user's Rize key, or use an admin key if Rize permits attribution. | | Re-running after a partial failure | The ID map plus a stable idempotency key is what makes this safe. Never re-run a loader that keys off row order. | | Fixed-fee projects | No direct equivalent in Rize. Billable flags and rates may not represent revenue the same way; reconcile manually. |

10. Suggested rollout

  1. Dry run. Migrate one month into a scratch Rize workspace. Reconcile fully. Expect to find at least one mapping surprise here — that is the point.
  2. Metadata load. Clients, projects, and tasks into the real workspace. Have someone who knows the account eyeball the project list before you go further.
  3. Backfill. Time entries, oldest first, chunked by month, checkpointing as you go.
  4. Reconcile. Run the full verification pass from section 8 and keep the output.
  5. Dual-run. A few days tracking in both tools, then a delta sync using updated_since on time_entries to pick up anything created or edited since the backfill.
  6. Cut over. Stop new time tracking in Harvest and retire it.

11. A lighter path for small migrations

If the migration is one person and a few hundred entries, the full pipeline is overkill. Rize's MCP server exposes the same operations as tools — create_client, create_project, create_time_entry, and their update counterparts — with an explicit idempotency_key argument on entry creation. Note that the MCP create_time_entry documents the overlap behavior directly: a matching active entry that overlaps the requested range is extended and updated rather than duplicated, so the sequential-stacking rule from §6.3 applies on this path too. Export a CSV from Harvest's time report, hand it to an assistant connected to the MCP server, and let it walk the rows. Slower per record, but no code to write and no code to maintain.

For a broader comparison of why teams leave Harvest in the first place, see our guide to the best Harvest alternatives. If you are ready to stop starting timers, try Rize free for 7 days.

Migrate without the manual work

Rize captures every billable hour automatically once it is installed. Start a free 7-day trial and see what your timesheet should look like.

“Rize has been a no-brainer for me.” — Ali Abdaal Read more →


Sources: Harvest API v2 documentation, Rize GraphQL API documentation, and Rize MCP tool definitions. Rate limits, quota tiers, and endpoint behaviour change — re-check the live docs before a production run.

Jonathan Wu
Jonathan WuHead of Growth

Jonathan leads growth at Rize, focusing on AI productivity measurement, go-to-market strategy, and helping teams prove ROI on their AI investments with time data.

Frequently Asked Questions

Yes. You can extract time entries from the Harvest API v2, transform them into Rize-compatible records, and load them through the Rize GraphQL API. Use a staging store and an idempotency key so the job is resumable.

Clients, projects, tasks, and time entries map directly. Users become team members matched by email. Project codes, fixed-fee projects, and expenses do not have direct equivalents and should be handled manually.

Harvest allows about 100 requests per 15 seconds on the general API and 100 requests per 15 minutes on the Reports API. Pace requests proactively, follow links.next for pagination, and sleep for the Retry-After value on HTTP 429.

Use a stable idempotency key such as harvest:<id> for every record, and maintain an ID map table that tracks the status of each Harvest source row. Load in dependency order: clients, then projects, then tasks, then time entries.

For one person and a few hundred entries, export a Harvest time-report CSV and use the Rize MCP server to create clients, projects, and time entries. Pass a stable idempotency key for every time entry and follow the same non-overlapping timestamp rules as a scripted migration.

Related Posts