Migrating
Bring the history. Check it before it lands.
A studio moving a decade of billing cares about exactly one thing: that the numbers still add up afterwards. So the importer writes nothing on the way in, shows you what it found, and waits for you to agree.
Step 1
Land
Your export is stored verbatim, byte for byte, with a content hash.
Step 2
Normalize
Thousands separators, three date conventions, four column layouts.
Step 3
Validate
Anything ambiguous becomes a discrepancy to resolve, never a guess.
Step 4
Dry run
A full report of what would be written. Nothing has been written yet.
Step 5
You approve
Check the totals against Harvest. This is the point of the exercise.
Step 6
Commit
Written in one pass, then reconciled against the source figures.
Proven against the source, not asserted
Your original rows are kept verbatim as they arrived, with a content hash, so any figure in the app traces back to the bytes it came from. After a commit the totals are recomputed and compared against the file. If they do not match, the import is marked discrepant rather than reconciled, and it tells you which figures disagree.
Anything unresolvable is recorded, never guessed
An ambiguous person, an unknown currency, a date format a file cannot prove — each becomes a discrepancy and blocks the commit until you decide. The dry run is your chance to catch a wrong assumption before it becomes your general ledger.
What actually goes wrong in a Harvest export
These are not hypotheticals. Each was found in a real export and each is handled by name.
Thousands separators inside quoted fields
A real export carried 1,600.0 in 19 of 76 rows. Handed to a naive parser, $1,600.00 silently becomes $1.00 -- it stops at the comma and does not complain. Separators only appear at four figures and up, so an importer tested on small values looks flawless while destroying the amounts that matter.
Three date conventions in one account's exports
ISO in the full report, 04/01/2026 in trimmed ones, 7/1/2025 in another. The slash forms are locale-dependent, and 04/01/2026 is either April or January depending which. The format is decided once per file from every date in it, and a file that cannot prove its convention is refused rather than guessed at.
A column called Employee Id that holds an email
In detailed exports it is an address; in the people export the same column is empty and the address is elsewhere. So the obvious join key between two files is neither stable nor named for what it contains. People are matched on email, then employee id, then name -- and reported as ambiguous rather than merged on a hunch.
Currency stored as a display name
The cell reads United States Dollar - USD. Keep the whole string and every later currency comparison quietly fails.
Invoiced rows with no invoice number
60 of 76 rows in one sample said Invoiced? = Yes and carried nothing to say which invoice. Those import as billed and flagged as unresolved, and the report says so. Guessing would produce a ledger that reconciles against nothing.
Two ways in
CSV exports
Works on any Harvest plan. Column layouts vary between accounts, so columns are matched by header name and an unrecognised shape is refused rather than guessed at.
The Harvest API
Carries what CSV cannot — most usefully the link between a billed hour and the invoice it was billed on, which no export column contains.
Importing is not metered. Forty thousand entries cost the same as forty.