How it works
- Step 1Export contacts from each CRM — **Salesforce**: Data Loader (Java 11+) → Export → Contact object → SOQL with
Id, FirstName, LastName, Email, Phone, Title, AccountId. **HubSpot**: Contacts → Actions → Export → select properties → CSV (no column cap; XLS caps at 256, XLSX at 16,384; files auto-zip if >2 MB). **Pipedrive**: Persons → ⋮ → Export → all data or a filtered view → CSV. - Step 2Fork the workflow — Click
Fork to my drafts. The fork copies the 5-node graph, auto-wirescsv-merger → csv-header-rename → csv-case-converter → csv-deduplicator → csv-cleaner, preserves each node's config, and snapshots it as version 1 in your private drafts. Forking needs only a signed-in account — no tier upgrade — and every node here runs on the Free tier. - Step 3Drop ALL CRM exports onto Step 1 together — csv-merger is the entry node and accepts a multi-file (
csv[]) drop, so you stage Salesforce + HubSpot + Pipedrive (plus Zoho / Copper) in one go. Order them by preference — the first file's rows win at the dedup step. Run union mode. - Step 4Extend the rename map to cover every CRM's headers — Open Step 2 (csv-header-rename). Because renaming runs after the merge, the one map must alias all CRMs. Open each CRM's CSV, read row 1, and add any missing aliases to the renames JSON (e.g. Pipedrive
Organization→Company, HubSpotPhone Number→Phone). Header matching is case-insensitive. - Step 5Confirm the dedup key is Email — Open Step 4 (csv-deduplicator) and make sure
Emailis selected in the 'Match columns' field so it keys on the email column (the field stores it as a tag/array). It always keeps the first occurrence and matches case-insensitively. Optionally narrow Step 3 to only the Email column if you want to preserve name casing. - Step 6Run, inspect the trace, and download — Click Run. The run panel shows a per-node trace — status, duration, and output size for each step — and the run is recorded in your workflow history. Download the final
cleaned.csv: one canonical contact list, one row per unique email, ready for your migration target's importer.
The real node chain (forked blueprint)
Exactly what the fork builds, in order. csv-merger leads because it is the only node whose input port accepts a multi-file drop.
| Step | Tool | Input → Output port | Blueprint config | What the runtime actually does |
|---|---|---|---|---|
| 1 | `csv-merger` | csv[] → csv | mode: union | Reads every dropped file, unions all columns, fills blanks. Throws if zero files. |
| 2 | `csv-header-rename` | csv → csv | renames: {…10 aliases…} | Maps each matched header (case-insensitive) to its canonical name; unmatched headers pass through. |
| 3 | `csv-case-converter` | csv → csv | caseType: lower, columnIndices: "" | Lower-cases every cell (all columns, because the index list is empty). Output-only; not required for dedup matching. |
| 4 | `csv-deduplicator` | csv → csv | columns: Email, strategy: first | Keeps the FIRST row per key, always case-insensitive. strategy is not read by the runner. Blank-key rows pass through. |
| 5 | `csv-cleaner` | csv → csv | trimWhitespace, removeEmptyRows, repairRows, deduplicate:false, normalizeSmartQuotes, normalizeHiddenWhitespace | Runtime reads only trimWhitespace; always removes empty rows + repairs rows; never re-dedupes. |
Fork / schedule / credential / tier matrix
What the orchestrator runtime actually grants this workflow. Grounded in the fork route, cron tick, tier-precheck, and tool registry.
| Capability | Supported here? | Detail |
|---|---|---|
| Fork to copy the graph | Yes | Any signed-in user can fork. Creates a private workflow row + version-1 snapshot, copies node configs, auto-wires consecutive ports, and writes a forked audit event. |
| Tier to fork / run | Free | All five nodes are CSV tools with no minTier, so the tier precheck returns required: free — runnable on the Free tier. (Per-file size limits still apply at the tool level: Free ~2 MB/file, Pro larger.) |
| Cron schedule out of the box | No | The blueprint ships scheduleCron: null, so the fork is unscheduled. The cron engine (Cloudflare Cron Triggers → /cron/tick → run queue) exists, but you must set a schedule yourself in the editor — and a scheduled run can't supply the manual multi-file drop this chain needs. |
| CRM credentials / API keys | None involved | This is a file-in / file-out chain — there are no CRM logins, OAuth tokens, or API keys anywhere in it. File contents are parsed in the browser and never sent to the server. |
| Run trace / history | Yes | Each run records a per-node trace (status, durationMs, outputSizeBytes, summary) plus an input/output summary; mutations (create, fork, run-triggered, run-cancelled) are logged to the audit-event table. |
| Error handling per node | abort (default) | A failing step aborts the run unless you set the node's error policy to retry (up to 3 attempts, linear backoff) or skip on the canvas. |
Per-CRM column-name variants
What each CRM exports for the same logical field. Fold these aliases into Step 2's single rename map.
| Canonical column | Salesforce | HubSpot | Pipedrive | Zoho | Notes |
|---|---|---|---|---|---|
First Name | FirstName (PascalCase, no space) | First Name | Name (combined first+last) | First Name | Pipedrive combines — split first with `csv-column-value-splitter` before merging |
Last Name | LastName | Last Name | see Name | Last Name | Same Pipedrive split issue |
Email | Email | Email | Email | Email | Standard across CRMs — this is the dedup key |
Phone | Phone, MobilePhone, HomePhone | Phone Number, Mobile Phone Number | Phone | Phone | Multi-phone fields differ — pick one canonical or keep them as separate columns |
Company | Account Name (related) or AccountId | Company | Organization | Account Name | Salesforce stores Account as a relationship; HubSpot stores the name directly |
Account ID / Contact ID | Id (18-char Salesforce ID) | Record ID (numeric) | Person ID (numeric) | Record Id | Preserve for round-trip migrations — keeps source-CRM linkage |
Cookbook
Real CRM-merge scenarios with the exact node config, grounded in how the orchestrator runtime executes each step.
3-way merge: Salesforce + HubSpot + Pipedrive
ExampleDrop all three exports onto Step 1 at once. The single rename map normalises the merged file; one pass produces the unified list.
Step 1 (csv-merger, union) — drop together, in preference order: 1. salesforce.csv (Id, FirstName, LastName, Email, Phone, AccountId, Title) 2. hubspot.csv (Record ID, First Name, Last Name, Email, Phone Number, Company) 3. pipedrive.csv (Person ID, Name, Email, Phone, Organization) -> merged.csv = union of all columns Step 2 (csv-header-rename) on the merged file: FirstName/first_name -> First Name LastName/last_name -> Last Name EmailAddress/email -> Email Account Name/Organization/CompanyName -> Company AccountId -> Account ID Step 3 (csv-case-converter, lower): emails (and all cells) lower-cased Step 4 (csv-deduplicator, Email): one row per unique email, first wins Step 5 (csv-cleaner): trim + drop empty rows + repair ragged rows
Salesforce-preferred merge (Salesforce wins on duplicates)
ExampleConsolidating into Salesforce: when the same contact exists in more than one CRM, keep the Salesforce row. There is no keep-last toggle in the runner, so this is controlled entirely by input order.
Step 1 input order (decides the winner at Step 4): 1. salesforce.csv <- preferred, listed FIRST 2. hubspot.csv 3. pipedrive.csv Step 4 (csv-deduplicator, key = Email): always keeps the FIRST occurrence -> the Salesforce row case-insensitive, so casing differences never cause a miss Result: Salesforce data wins; HubSpot/Pipedrive only contribute emails Salesforce did not already have.
Pipedrive Name column needs splitting first
ExamplePipedrive stores Name as one combined field. Split it BEFORE Step 1 so the merged file already has First/Last columns to rename.
Pipedrive raw: Person ID,Name,Email 12345,John Smith,john@example.com 12346,Maria Garcia Lopez,maria@example.com Pre-step: csv-column-value-splitter on Name (space) -> Person ID,First Name,Last Name,Email 12345,John,Smith,john@example.com 12346,Maria,Garcia Lopez,maria@example.com (greedy: rest -> Last Name) Then drop the split Pipedrive file with the others onto Step 1. Multi-word FIRST names (Maria Jose) still misplit -> fix manually.
Case-different emails across CRMs collapse automatically
ExampleThe same person stored with three different email casings still dedupes to one row — because Step 4 is always case-insensitive, you would get this even without Step 3.
Salesforce: Email = John.Smith@Gmail.com
HubSpot: Email = john.smith@gmail.com
Pipedrive: Email = JOHN.SMITH@GMAIL.COM
After Step 1+2: 3 rows, 3 casings
After Step 3: 3 rows, all john.smith@gmail.com (output tidy)
After Step 4: 1 row (deduper lower-cases the KEY internally,
so it would collapse these even if Step 3 were removed)Contacts with no email survive the dedup
ExampleThe deduplicator passes blank-key rows straight through instead of merging them, so partial CRM records aren't silently dropped — but they also aren't deduped against each other.
Merged rows: First Name,Email John,john@example.com John,john@example.com <- duplicate, collapses Jane, <- blank email, PASSES THROUGH Jane, <- blank email, ALSO passes through (NOT merged) After Step 4: John,john@example.com (1 of 2 kept) Jane, (both blank-email Janes survive) If blank-email rows must be reviewed, run csv-find-replace or a column filter after Step 5 to isolate rows with an empty Email.
Edge cases and verbatim errors
Step 1 receives zero files (nothing dropped on the entry node)
errorcsv-merger throws CSV Merge requires at least one file input when no file reaches its csv[] port — the run aborts on the first step. This happens if you forget to stage your exports or wired the entry node downstream of an empty branch. Drop your CRM CSVs onto the merger node, then re-run.
csv-merger run in strict mode on mismatched CRM schemas
rejectIf you switch Step 1 to strict, the merger rejects files whose columns don't line up — which is exactly the situation here, since each CRM exports different headers. Keep mode: union so every column is preserved with blanks; strict is for already-identical schemas (see the GA4 / Search Console merge workflow where strict is appropriate).
Dedup keys on the wrong column after forking
invalidcsv-deduplicator's match field expects a column NAME held as a tag/array. The blueprint stores Email as a bare string, so confirm Email is selected in the deduplicator's 'Match columns' field on the canvas before running — otherwise the key resolution can fall back to the first column and dedupe on the wrong field. Re-pick Email and the runtime keys correctly.
'Keep last' / latest-record-wins expected
Not available in the runnerThe orchestrator deduplicator always keeps the FIRST occurrence; its strategy config is not read at run time. To make the newest record win, sort the merged file by Last Activity Date descending with `csv-sorter` BEFORE Step 4, so the freshest row is the first one the deduper sees.
Contacts with no email address
Passed through, not mergedRows with a blank Email key are passed through unchanged — they are neither dropped nor deduped against each other. After the run, isolate them with a column filter on an empty Email and resolve manually (assign emails, or dedupe on a fallback key like Phone in a second pass).
Step 5 smart-quote / hidden-whitespace options don't take effect
Runtime ignores those flagsThe blueprint sets normalizeSmartQuotes and normalizeHiddenWhitespace on csv-cleaner, but the orchestrator's cleaner handler only reads trimWhitespace (it always trims, removes empty rows, and repairs rows). If you specifically need curly-quote folding or NBSP normalisation, run a dedicated `csv-find-replace` pass after Step 5 to target those characters.
Pipedrive's `Name` field is one combined column
Pre-merge split requiredPipedrive stores names in a single Name field while Salesforce and HubSpot use separate First/Last columns. Split it with `csv-column-value-splitter` BEFORE dropping it onto Step 1 — first word is First Name, the rest is Last Name. Greedy split handles Garcia Lopez correctly; compound first names (Maria Jose) misplit and need a manual fix.
Salesforce 15-char vs 18-char IDs
ID variantsSalesforce uses 15-char case-sensitive IDs internally; the API/UI often append a 3-char checksum to make 18-char IDs. Both refer to the same record. If you dedupe on Account ID instead of Email, normalise length first with `csv-find-replace` (truncate to 15 or pad to 18) — otherwise the same record appears as two rows.
HubSpot auto-zips exports larger than 2 MB
Source-side artefactHubSpot zips CSV exports over 2 MB. Extract the ZIP before dropping it onto Step 1 — the workflow does not auto-unzip. The extracted CSV merges cleanly with the others through union mode.
Custom fields that exist in only one CRM
Union-mode artefactsSalesforce custom fields (Industry__c) and HubSpot custom properties don't share a namespace. Union mode keeps them all — rows from other CRMs get empty cells in those columns. Decide per field whether to keep it (add the alias to Step 2's map or leave it as-is) or drop it with `csv-column-value-splitter`'s sibling column-remover before importing to your target CRM.
Frequently asked questions
Why does csv-merger run first instead of renaming each CRM first?
Because csv-merger is the only node in this chain whose input port accepts a multi-file (csv[]) drop — it is the natural entry point for several CRM exports at once. The blueprint therefore merges first, then renames the single combined file. The practical consequence: Step 2's rename map must cover every CRM's column aliases together (which is why the blueprint map lists FirstName, first_name, LastName, last_name, Organization, Account Name, CompanyName and more).
Do I have to run the case-conversion step for dedup to work?
No. The deduplicator is always case-insensitive — it lower-cases each key internally (caseSensitive: false is hardcoded in the runtime), so John@x.com and john@x.com collapse whether or not you ran Step 3. Step 3 (csv-case-converter to lower) exists to make the OUTPUT casing tidy, not to enable matching. If you want to keep name casing, narrow Step 3 to only the Email column index.
Which CRM becomes the 'source of truth' on duplicates?
Whichever you list FIRST in the Step 1 multi-file drop. The deduplicator always keeps the first occurrence it sees, so input order is the control. There is no keep-last switch in the runner — to make the newest record win instead, sort by Last Activity Date descending with `csv-sorter` before Step 4.
How do I make sure dedup keys on Email and not some other column?
Open the csv-deduplicator node on the canvas and confirm Email is selected in its 'Match columns' field (it stores the choice as a tag/array). The blueprint seeds Email, but verify it after forking — if the key doesn't resolve to a real column name the runtime can fall back to the first column. Once Email is selected, dedup keys on the email column, case-insensitively.
What happens to contacts that have no email?
They pass through the deduplicator unchanged — blank-key rows are neither dropped nor merged with each other. So two emailless 'Jane' rows both survive. After the run, filter for rows with an empty Email and resolve them manually, or run a second dedup pass on a fallback key like Phone.
Will contact PII be uploaded to JAD Apps?
No. Every tool in this chain runs client-side — the CSVs are parsed in your browser and the contents never reach a server. The only server write is a fire-and-forget audit event recording that a run happened (event type, workflow id, a slim graph diff) — never row data. There are no CRM credentials in this workflow at all, so there's nothing to store or leak on that front.
Can I schedule this to run automatically on a cron?
Not usefully out of the box. The blueprint ships with no schedule (scheduleCron: null), so a fresh fork is unscheduled. The platform does have a cron engine (Cloudflare Cron Triggers fire /cron/tick, which enqueues runs for any workflow that has a schedule_cron), and you can set a schedule in the editor — but this chain's entry node needs a manual multi-file drop that a scheduled run can't supply. It's built for batch migration, not unattended sync.
What tier do I need to fork and run this?
Free. Forking only requires a signed-in account, and every node here is a CSV tool with no minimum tier, so the tier precheck returns required: free. Per-file processing limits still apply at the tool level (roughly 2 MB/file on Free, larger on Pro), but nothing in the chain is tier-gated.
What exactly does forking do?
It builds a graph by placing each blueprint node in a horizontal chain, auto-wiring consecutive nodes via their first compatible out→in ports, copies each node's config, creates a private workflow row owned by you, snapshots it as version 1, and writes a forked audit event. You land in your drafts with a fully wired, editable copy — change the rename map, reorder inputs, add a CRM, then run.
Can the workflow handle Zoho / Copper / Close.io / Insightly?
Yes — they share the same core fields (email, name, phone, company), just under different headers. Add each new CRM's aliases to Step 2's single rename map (since renaming happens after the merge, one map covers them all), then include those exports in the Step 1 drop. No structural change to the chain is needed.
What about deal / opportunity exports instead of contacts?
Same chain applies. Salesforce Opportunity, HubSpot Deal, Pipedrive Deal each name the same logical fields (Amount, Stage, Close Date, Owner) differently. Build the Step 2 rename map for deal fields and change the Step 4 dedup key from Email to your cross-CRM deal identifier. Everything else (union merge, lower-case, clean) is unchanged.
How is this different from a paid migration service like Trujay or Import2?
Those are paid services that handle full CRM-to-CRM migrations — custom fields, attachments, history, and ongoing sync. This workflow handles just the CSV export → merge → import step, browser-local, with no credentials and no ongoing sync. It's the right tool when your migration is one-off, when PII must stay on your machine, or when you're building a unified outreach list rather than a full migration. For attachment- and history-aware migration, the paid services are usually worth it.
Local-first by design
This workflow executes entirely on your jadapps-runner. API keys, database credentials, and OAuth tokens are stored in an AES-GCM-encrypted vault on your device — they are never uploaded to JAD Apps' servers. The server only stores the workflow graph (the recipe), not the secrets.