Set up bidirectional HubSpot-Salesforce sync using n8n
Install this workflow
Download the n8n workflow JSON and import it into your n8n instance.
crm-sync.n8n.jsonPrerequisites
- n8n instance — n8n cloud or self-hosted
- HubSpot private app token with
crm.objects.contacts.read,crm.objects.contacts.write,crm.objects.companies.read,crm.objects.companies.write,crm.objects.deals.read, andcrm.objects.deals.writescopes - Salesforce connected app with OAuth 2.0 credentials (Consumer Key + Consumer Secret) and API access enabled
- n8n credentials configured for both HubSpot and Salesforce
- A custom property
last_synced_by(single-line text) created on contacts, companies, and deals in both HubSpot and Salesforce
Why n8n?
n8n gives you full programmatic control over every aspect of the sync — field mapping, conflict resolution, transformation logic, and error handling. The native HubSpot-Salesforce integration covers standard objects well, but if you need custom object sync without Enterprise, conditional field transformations (e.g., concatenating first and last name into a single field), or integration with third-party services during the sync, n8n is the only visual approach that supports it.
The trade-off is real complexity. You're building and maintaining two parallel workflows (one per direction), a conflict resolution layer, and a reconciliation job. This is the hardest approach in this recipe, and it requires comfort with JavaScript for the Code nodes. Self-hosted n8n is free with unlimited executions, which matters here — bidirectional sync generates high execution volume. n8n Cloud teams should estimate carefully or self-host.
How it works
- HubSpot Trigger node — fires on contact, company, or deal property changes via webhook (near-instant)
- Salesforce Trigger node — fires on record updates via polling (1-5 minute delay)
- Code nodes — loop-prevention guards, field mapping, conflict resolution logic
- HTTP Request / Salesforce nodes — record lookups and upserts in the target system
- Schedule Trigger — daily reconciliation to catch drift and fix mismatches
You'll build three workflows: HubSpot-to-Salesforce, Salesforce-to-HubSpot, and a scheduled reconciliation job.
Step 1: Set up credentials
HubSpot: In n8n, go to Credentials and add a new HubSpot credential. Enter your Private App Token. The private app needs read and write scopes for contacts, companies, and deals.
Salesforce: Add a new Salesforce credential using OAuth 2.0. You'll need the Consumer Key and Consumer Secret from your Salesforce connected app, plus your instance URL (e.g., https://yourorg.my.salesforce.com). n8n will redirect you to Salesforce to authorize.
Salesforce Professional Edition does not include API access by default — you need the API add-on ($25/user/month) or an Enterprise/Unlimited edition. Verify your edition supports API calls before proceeding.
Step 2: Build the HubSpot-to-Salesforce workflow
Create a new workflow. This handles changes originating in HubSpot.
Add the HubSpot Trigger node
- Authentication: Select your HubSpot credential
- Event: Contact Property Changed
The HubSpot Trigger fires for a single object type. To sync contacts, companies, and deals, you need three separate trigger nodes (or three separate workflows). Start with contacts, then duplicate the pattern for companies and deals.
Add a loop-prevention guard
Add a Code node immediately after the trigger. This checks whether the change was made by the sync itself — if so, skip it to prevent infinite loops.
const items = $input.all();
const results = [];
for (const item of items) {
const contactId = item.json.objectId;
// Fetch the contact to check the guard field
const response = await this.helpers.httpRequest({
method: 'GET',
url: `https://api.hubapi.com/crm/v3/objects/contacts/${contactId}`,
qs: { properties: 'last_synced_by,firstname,lastname,email,phone,jobtitle,company' },
headers: {
Authorization: `Bearer ${$credentials.hubSpotApi.accessToken}`,
},
});
const lastSyncedBy = response.properties.last_synced_by;
// If Salesforce just wrote this change, skip it
if (lastSyncedBy === 'salesforce') {
continue;
}
results.push({ json: { contactId, properties: response.properties } });
}
return results;Without the last_synced_by check, a change in HubSpot triggers a Salesforce update, which triggers the Salesforce-to-HubSpot workflow, which updates HubSpot, which triggers this workflow again — an infinite loop that burns through API limits in minutes.
Look up the matching Salesforce record
Add an HTTP Request node to find the corresponding Salesforce contact by email:
- Method: GET
- URL:
https://yourorg.my.salesforce.com/services/data/v59.0/query - Authentication: Predefined -> Salesforce
- Query params:
q=SELECT Id, FirstName, LastName, Email, Phone, Title FROM Contact WHERE Email = '{{ $json.properties.email }}'
Map fields and upsert to Salesforce
Add a Code node to transform HubSpot properties into Salesforce fields:
const hubspot = $('Loop Prevention Guard').first().json.properties;
const sfLookup = $('Salesforce Lookup').first().json;
const sfRecords = sfLookup.records || [];
const existingId = sfRecords.length > 0 ? sfRecords[0].Id : null;
const salesforcePayload = {
FirstName: hubspot.firstname || '',
LastName: hubspot.lastname || '',
Email: hubspot.email,
Phone: hubspot.phone || '',
Title: hubspot.jobtitle || '',
last_synced_by__c: 'hubspot',
};
return [{
json: {
salesforceId: existingId,
payload: salesforcePayload,
operation: existingId ? 'update' : 'create',
}
}];Add an IF node that checks {{ $json.operation }} equals update, then route to two HTTP Request nodes:
Update path (PATCH):
- Method: PATCH
- URL:
https://yourorg.my.salesforce.com/services/data/v59.0/sobjects/Contact/{{ $json.salesforceId }} - Body:
{{ $json.payload }}
Create path (POST):
- Method: POST
- URL:
https://yourorg.my.salesforce.com/services/data/v59.0/sobjects/Contact - Body:
{{ $json.payload }}
Instead of the lookup-then-branch pattern, create a custom External ID field in Salesforce (e.g., HubSpot_Contact_ID__c) and use the Salesforce upsert endpoint: PATCH /sobjects/Contact/HubSpot_Contact_ID__c/{contactId}. This creates or updates in a single call.
Step 3: Build the Salesforce-to-HubSpot workflow
Create a second workflow that mirrors Step 2 in the opposite direction.
Add the Salesforce Trigger node
- Authentication: Select your Salesforce credential
- Object: Contact
- Event: Updated
The Salesforce Trigger polls for changes every 1-5 minutes depending on your n8n plan (5 min on Starter, 1 min on Pro). Changes are not instant — there will be a short delay before the sync fires.
Add a loop-prevention guard
Add a Code node that checks the last_synced_by__c field on the Salesforce record:
const items = $input.all();
const results = [];
for (const item of items) {
const lastSyncedBy = item.json.last_synced_by__c;
// If HubSpot just wrote this change, skip it
if (lastSyncedBy === 'hubspot') {
continue;
}
results.push({ json: item.json });
}
return results;Look up the matching HubSpot contact
Add an HTTP Request node to find the HubSpot contact by email:
- Method: POST
- URL:
https://api.hubapi.com/crm/v3/objects/contacts/search - Authentication: Predefined -> HubSpot
- Body:
{
"filterGroups": [{
"filters": [{
"propertyName": "email",
"operator": "EQ",
"value": "{{ $json.Email }}"
}]
}],
"properties": ["firstname", "lastname", "email", "phone", "jobtitle", "hs_lastmodifieddate"]
}Map fields and upsert to HubSpot
Add a Code node to transform Salesforce fields into HubSpot properties:
const sf = $('SF Loop Prevention Guard').first().json;
const hsLookup = $('HubSpot Lookup').first().json;
const hsResults = hsLookup.results || [];
const existingId = hsResults.length > 0 ? hsResults[0].id : null;
const hubspotPayload = {
properties: {
firstname: sf.FirstName || '',
lastname: sf.LastName || '',
email: sf.Email,
phone: sf.Phone || '',
jobtitle: sf.Title || '',
last_synced_by: 'salesforce',
}
};
return [{
json: {
hubspotId: existingId,
payload: hubspotPayload,
operation: existingId ? 'update' : 'create',
}
}];Route through an IF node, then:
Update path (PATCH):
- Method: PATCH
- URL:
https://api.hubapi.com/crm/v3/objects/contacts/{{ $json.hubspotId }} - Body:
{{ $json.payload }}
Create path (POST):
- Method: POST
- URL:
https://api.hubapi.com/crm/v3/objects/contacts - Body:
{{ $json.payload }}
Step 4: Add conflict resolution
When the same record is updated in both systems between sync cycles, you need rules to decide which value wins. Add a Code node in each workflow, between the lookup and the upsert, that compares timestamps.
const sourceRecord = $('Loop Prevention Guard').first().json.properties;
const targetRecords = $('Salesforce Lookup').first().json.records || [];
// No conflict if the target record doesn't exist yet
if (targetRecords.length === 0) {
return [{ json: { conflict: false, action: 'create' } }];
}
const target = targetRecords[0];
// Compare last-modified timestamps
const sourceModified = new Date(sourceRecord.hs_lastmodifieddate);
const targetModified = new Date(target.SystemModstamp);
// Last-write-wins: if the source is newer, proceed with sync
if (sourceModified >= targetModified) {
return [{ json: { conflict: false, action: 'update' } }];
}
// Target is newer — skip this sync to avoid overwriting newer data
return [{ json: { conflict: true, action: 'skip', reason: 'Target record is newer' } }];Add an IF node after this: if {{ $json.action }} equals skip, route to a Slack node that logs the skipped record for review. Otherwise, continue to the upsert.
Last-write-wins is the simplest strategy, but you can do better. Instead of comparing record-level timestamps, compare field by field. For example: always trust Salesforce for revenue fields (Amount, AnnualRevenue), always trust HubSpot for marketing fields (lifecyclestage, lead_source). This requires more Code node logic but prevents marketing data from being overwritten by sales updates.
Step 5: Add a scheduled reconciliation workflow
Triggers are not bulletproof — webhooks can fail, polling can miss rapid changes, and network issues cause gaps. Build a third workflow that runs on a schedule to catch drift.
Add a Schedule Trigger
- Interval: Every 24 hours (or weekly for lower-volume orgs)
Fetch recently modified records from both systems
Add two parallel branches:
Branch 1 — HTTP Request to HubSpot:
- Method: POST
- URL:
https://api.hubapi.com/crm/v3/objects/contacts/search - Body:
{
"filterGroups": [{
"filters": [{
"propertyName": "hs_lastmodifieddate",
"operator": "GTE",
"value": "{{ new Date(Date.now() - 86400000).toISOString() }}"
}]
}],
"properties": ["firstname", "lastname", "email", "phone", "jobtitle", "last_synced_by"],
"limit": 100
}Branch 2 — HTTP Request to Salesforce:
- Method: GET
- URL:
https://yourorg.my.salesforce.com/services/data/v59.0/query - Query params:
q=SELECT Id, FirstName, LastName, Email, Phone, Title, SystemModstamp, last_synced_by__c FROM Contact WHERE SystemModstamp >= YESTERDAY
Compare and fix drift
Add a Code node that joins records by email, compares field values, and outputs a list of mismatches:
const hsContacts = $('Fetch HubSpot Contacts').first().json.results || [];
const sfContacts = $('Fetch Salesforce Contacts').first().json.records || [];
// Index Salesforce contacts by email
const sfByEmail = {};
for (const sf of sfContacts) {
if (sf.Email) sfByEmail[sf.Email.toLowerCase()] = sf;
}
const mismatches = [];
for (const hs of hsContacts) {
const email = (hs.properties.email || '').toLowerCase();
const sf = sfByEmail[email];
if (!sf) continue;
const diffs = [];
if (hs.properties.firstname !== sf.FirstName) diffs.push(`firstname: HS="${hs.properties.firstname}" SF="${sf.FirstName}"`);
if (hs.properties.lastname !== sf.LastName) diffs.push(`lastname: HS="${hs.properties.lastname}" SF="${sf.LastName}"`);
if (hs.properties.phone !== sf.Phone) diffs.push(`phone: HS="${hs.properties.phone}" SF="${sf.Phone}"`);
if (hs.properties.jobtitle !== sf.Title) diffs.push(`jobtitle: HS="${hs.properties.jobtitle}" SF="${sf.Title}"`);
if (diffs.length > 0) {
const hsModified = new Date(hs.properties.hs_lastmodifieddate || 0);
const sfModified = new Date(sf.SystemModstamp);
const winner = hsModified >= sfModified ? 'hubspot' : 'salesforce';
mismatches.push({
email,
hubspotId: hs.id,
salesforceId: sf.Id,
diffs,
winner,
});
}
}
return mismatches.map(m => ({ json: m }));Route mismatches through additional nodes that apply the same upsert logic from Steps 2-3 (using the winner field to determine sync direction), then send a summary to Slack:
Reconciliation complete: {{ $json.length }} mismatches found and resolved.Both HubSpot and Salesforce paginate results. The HubSpot search API returns a maximum of 100 results per request with an after cursor. Salesforce SOQL returns 2,000 records per response with a nextRecordsUrl. For orgs with more than 100 contacts modified per day, add pagination loops to fetch all records.
Step 6: Activate and monitor
- Test each workflow individually — manually update a contact in HubSpot and verify it appears in Salesforce (and vice versa)
- Verify loop prevention — after a sync, confirm the target record has
last_synced_byset correctly and that the reverse workflow does not re-trigger - Enable error workflows — in each workflow's settings, set an Error Workflow that sends a Slack notification to
#sync-errorswhen any node fails - Toggle all three workflows to Active
- Monitor for the first week — check the Slack error channel daily and review the reconciliation report
Do not activate bidirectional sync across your entire database on day one. Start with a test segment — filter by a specific company, owner, or tag. Once you've confirmed the sync runs cleanly for a few days, expand to the full dataset.
Troubleshooting
Common questions
How do I prevent sync loops?
Use a guard field (last_synced_by in HubSpot, last_synced_by__c in Salesforce) on every synced object. Before processing a change, the Code node checks whether the change was made by the sync itself. If last_synced_by equals the other system's name, the workflow skips the record. Both workflows must set this field on every upsert.
Can I sync custom objects with n8n?
Yes. Unlike the native HubSpot-Salesforce integration, which requires HubSpot Enterprise for custom object sync, n8n can sync any object type using HTTP Request nodes against both REST APIs. Define your field mappings in a Code node and use the same guard-field pattern for loop prevention.
How many executions will this use?
Each record change triggers one execution in the corresponding workflow. If your team updates 100 contacts per day in HubSpot, that's roughly 100 executions for the HubSpot-to-Salesforce workflow. The Salesforce-to-HubSpot workflow adds executions for changes originating there. The reconciliation workflow uses 1 execution per scheduled run. For a team generating 50-200 record changes per day across both systems, expect 3,000-12,000 executions per month.
Cost
- n8n Cloud Starter: $24/mo for 2,500 executions. Likely insufficient for bidirectional sync unless your volume is very low (under 40 changes/day total).
- n8n Cloud Pro: $60/mo for 10,000 executions. Covers most mid-market teams with moderate update volume.
- Self-hosted: Free, unlimited executions. Recommended for bidirectional sync due to high execution volume. Requires a server ($5-20/mo on Railway, Render, or a VPS).
- Estimate: Bidirectional sync of 50-200 record changes per day = 3,000-12,000 executions per month. Self-hosted or Cloud Pro recommended.
Next steps
- Add company and deal sync — duplicate the contact workflows for companies (HubSpot companies to Salesforce accounts) and deals (HubSpot deals to Salesforce opportunities), with object-specific field mappings
- Field-level conflict rules — replace last-write-wins with per-field priority (e.g., Salesforce always wins for revenue fields, HubSpot always wins for marketing fields)
- Sync dashboard — log every sync event to Google Sheets or a database, then build a dashboard showing sync volume, conflict rate, and error rate over time
- Dedup on initial sync — before activating, run a one-time reconciliation to match existing records by email and link them, preventing duplicates on the first trigger
Looking to scale your AI operations?
We build and optimize automation systems for mid-market businesses. Let's discuss the right approach for your team.