We moved 5,000 contacts from Notion to Orin last quarter. On paper, it looked straightforward—export, map fields, import. In practice, the CSV export orphaned 340 linked records, flattened 18 rollup fields into text, and scattered contact history across 12 disconnected sheets. This is the real story of what broke, why it broke, and the API-first playbook that fixed it. Why the CSV export failed Notion's CSV export is single-table. It works fine for flat data. It catastrophically fails when your CRM schema has relationships. Our Notion workspace had: Contacts linked to Companies (many-to-many in Notion, one-to-many in Orin) Rollup fields that calculated total deal value per contact (e.g., "Active Deals Won Value") Related records from Deals, Interactions, and Tasks Property histories stored in a separate database (when did the status last change, who changed it) The CSV export produced a single row per contact. Every linked record became a text blob. Every rollup became a snapshot—not a live formula. Every history entry vanished. We ended up with 5,000 contacts that had zero relationship to the 3,200 deals and 12,000 interactions that should have been attached to them. The three things that broke immediately after import 1. Linked records flattened into text Notion exported company links as comma-separated text: "Acme Corp, Beta Inc, Gamma Ltd". Orin's contact-to-company field expected either a single company ID or a properly structured array. The import treated the text as a literal value, not a reference. Our sales team had 5,000 contacts with zero company assignments, and 3,200 companies with zero contact assignments. The revenue pipeline was invisible. 2. Rollup fields became stale numbers We had a Notion rollup: "Sum of deal value where status = 'Won' and date_closed in last 12 months". The CSV exported this as a static number—$847,200 for contact ID 14502. It was right on export day. By day three, it was wrong. Deals closed, deal values changed, statuses flipped. The rollup in Orin didn't recalculate. Our VPs were making forecast decisions on six-month-old numbers. 3. Contact history and audit trails orphaned Notion stored interaction history in a separate "Contact Updates" database, linked via a field called contact_id. The CSV export didn't include the linked database at all. We imported 5,000 contacts with zero history. When a deal closed, we had no record of when the contact status changed, who changed it, or what triggered the change. For compliance and forecasting, this was unusable. The API-first approach that recovered the relationships We rebuilt the migration in three phases: canonical extract , relationship mapping , and staged validation . Phase 1: Extract via Notion API (not CSV) We used the Notion API to pull all four databases: Contacts (5,000 records) Companies (840 records) Deals (3,200 records) Contact Updates (12,000 records) The API returned each linked record as a structured ID, not a text string. Company links came back as arrays of company_ids. We could programmatically resolve each ID to a real company, then load it into Orin's contact.company field as a proper foreign key. For rollups, we extracted both the static value (for reference) and the source query. We documented what each rollup should calculate, then recreated it in Orin's CRM as a formula field or view filter —so it would live-recalculate. Phase 2: Relationship mapping in a staging database Before we touched production, we ran the API extract into Orin's staging environment. We built a mapping spreadsheet: Notion contact_id → Orin contact_id Notion company_id → Orin company_id (with validation: does every company_id in the contact's company array exist in Orin's company table?) Notion deal_id → Orin deal_id (with validation: does every deal attached to this contact have a valid contact_id in Orin?) Notion update_id → Orin timeline entry (contact updates became activity log entries, preserving timestamp and actor) We found 340 "orphan" records: deals or updates with a contact_id that didn't exist in our contact list. These had been soft-deleted in Notion (archived but not purged). We recovered 287 of them from Notion's API (they're still accessible for 30 days) and marked 53 as permanently deleted with a reason and date in Orin's audit log. Phase 3: Staged import with validation gates We imported in this order: Companies first (840 records, dependencies: none). Validation: every company has a name and at least one unique identifier (domain, tax ID, or email domain). Contacts second (5,000 records, dependencies: companies). Validation: every contact has an email or phone; every company_id in the contact's company array exists; no duplicate emails across the import. Deals third (3,200 records, dependencies: contacts, companies). Validation: every deal has a contact_id and company_id that exist in Orin; deal value is numeric; close date is in the future or within 24 months of today. Activity logs last (12,000 records, de