Skip to main content

Multi-CRM Sync Automation with Codex: HubSpot + Salesforce Unified [2026]

· 9 min read
MarketBetter Team
Content Team, marketbetter.ai

Your company runs HubSpot for marketing. Sales uses Salesforce. The two don't talk.

Every week, someone manually exports contacts from HubSpot, cleans the data in Excel, and imports to Salesforce. Deals created in Salesforce never make it back to HubSpot. Marketing can't see which MQLs became opportunities.

The result: Marketing thinks their campaigns are working. Sales thinks marketing sends garbage leads. Nobody can prove anything because the data lives in two places.

Sound familiar? You're not alone. This is one of the most common problems in B2B GTM.

Enterprise solutions like Workato, Tray.io, or HubSpot's native Salesforce sync cost $20K-50K per year and still require massive configuration.

What if you could build a smarter sync for $100/month?

HubSpot and Salesforce connected through AI orchestration layer

The Problem with Traditional Sync Tools

Native HubSpot-Salesforce Sync

HubSpot offers a native Salesforce integration. It works... sort of. Problems:

  • One-way sync for many fields
  • Limited conflict resolution
  • No custom logic for edge cases
  • Breaks when you have custom objects

iPaaS Tools (Workato, Tray, Zapier)

These are better but:

  • $20K-50K+/year for enterprise plans
  • Complex visual builders that become unmaintainable
  • No intelligence—just "if this, then that"
  • Can't handle nuanced business rules

The Real Challenge: Business Logic

The hard part isn't syncing fields. It's answering questions like:

  • If a contact exists in both systems with different emails, which is correct?
  • When a deal stage changes in Salesforce, how does that map to HubSpot lifecycle stages?
  • If marketing updates a lead score in HubSpot, should it overwrite the Salesforce score?
  • How do you handle records created by integrations vs. humans?

This is where AI shines.

The Architecture

Here's what we're building:

[HubSpot Webhook] ←→ [Sync Engine] ←→ [Salesforce Webhook]

[Codex Agent]

[Conflict Resolution]

[Bidirectional Updates]

The Codex agent handles:

  1. Field mapping — Translates between CRM schemas
  2. Conflict resolution — Decides which record is authoritative
  3. Validation — Ensures data quality before sync
  4. Logging — Tracks every change for debugging

AI agent resolving CRM data conflicts

Prerequisites

  • OpenAI Codex CLI installed
  • HubSpot API key (Private App)
  • Salesforce Connected App credentials
  • Node.js 18+
  • A database for sync state (PostgreSQL recommended)

Step 1: Define Your Sync Map

First, document how fields map between systems:

// config/field-map.js
module.exports = {
contact: {
// HubSpot field -> Salesforce field
'email': 'Email',
'firstname': 'FirstName',
'lastname': 'LastName',
'phone': 'Phone',
'company': 'Company',
'jobtitle': 'Title',
'lifecyclestage': 'Status__c', // Custom field
'hs_lead_score': 'Lead_Score__c',

// Complex mappings
'hs_analytics_source': {
salesforce_field: 'LeadSource',
transform: (value) => {
const sourceMap = {
'ORGANIC_SEARCH': 'Web',
'PAID_SEARCH': 'Paid Search',
'SOCIAL_MEDIA': 'Social',
'EMAIL_MARKETING': 'Email',
'DIRECT_TRAFFIC': 'Direct',
'REFERRALS': 'Referral'
};
return sourceMap[value] || 'Other';
}
}
},

deal: {
'dealname': 'Name',
'amount': 'Amount',
'closedate': 'CloseDate',
'dealstage': {
salesforce_field: 'StageName',
transform: (stage) => {
const stageMap = {
'appointmentscheduled': 'Discovery',
'qualifiedtobuy': 'Qualification',
'presentationscheduled': 'Demo',
'decisionmakerboughtin': 'Proposal',
'contractsent': 'Negotiation',
'closedwon': 'Closed Won',
'closedlost': 'Closed Lost'
};
return stageMap[stage] || 'Unknown';
}
}
},

company: {
'name': 'Name',
'domain': 'Website',
'industry': 'Industry',
'numberofemployees': 'NumberOfEmployees',
'annualrevenue': 'AnnualRevenue'
}
};

Step 2: Build the Sync Engine Core

// lib/sync-engine.js
const HubSpot = require('@hubspot/api-client');
const jsforce = require('jsforce');
const fieldMap = require('../config/field-map');

class SyncEngine {
constructor() {
this.hubspot = new HubSpot.Client({ accessToken: process.env.HUBSPOT_TOKEN });
this.salesforce = new jsforce.Connection({
loginUrl: process.env.SF_LOGIN_URL,
accessToken: process.env.SF_ACCESS_TOKEN,
instanceUrl: process.env.SF_INSTANCE_URL
});
this.syncState = new SyncStateDB(); // Your state store
}

async syncHubSpotToSalesforce(objectType, hubspotId) {
// Get HubSpot record
const hsRecord = await this.getHubSpotRecord(objectType, hubspotId);

// Check if already synced
const existingSfId = await this.syncState.getSalesforceId(objectType, hubspotId);

if (existingSfId) {
// Update existing record
return this.updateSalesforceRecord(objectType, existingSfId, hsRecord);
} else {
// Find or create in Salesforce
const sfRecord = await this.findSalesforceMatch(objectType, hsRecord);

if (sfRecord) {
// Link existing and update
await this.syncState.linkRecords(objectType, hubspotId, sfRecord.Id);
return this.updateSalesforceRecord(objectType, sfRecord.Id, hsRecord);
} else {
// Create new
return this.createSalesforceRecord(objectType, hsRecord, hubspotId);
}
}
}

async findSalesforceMatch(objectType, hsRecord) {
// Use email for contacts, domain for companies, etc.
if (objectType === 'contact') {
const email = hsRecord.properties.email;
if (!email) return null;

const results = await this.salesforce.sobject('Contact')
.find({ Email: email })
.limit(1);

return results[0] || null;
}

if (objectType === 'company') {
const domain = hsRecord.properties.domain;
if (!domain) return null;

const results = await this.salesforce.sobject('Account')
.find({ Website: { $like: `%${domain}%` } })
.limit(1);

return results[0] || null;
}

return null;
}

transformHubSpotToSalesforce(objectType, hsProperties) {
const mapping = fieldMap[objectType];
const sfFields = {};

for (const [hsField, sfConfig] of Object.entries(mapping)) {
const value = hsProperties[hsField];
if (value === undefined || value === null) continue;

if (typeof sfConfig === 'string') {
// Simple mapping
sfFields[sfConfig] = value;
} else {
// Complex mapping with transform
sfFields[sfConfig.salesforce_field] = sfConfig.transform(value);
}
}

return sfFields;
}
}

module.exports = { SyncEngine };

Step 3: Intelligent Conflict Resolution with Codex

Here's where the AI magic comes in. When records conflict, Codex decides:

// lib/conflict-resolver.js
const { Codex } = require('@openai/codex');

const codex = new Codex({ apiKey: process.env.OPENAI_API_KEY });

async function resolveConflict(hubspotRecord, salesforceRecord, fieldName) {
const prompt = `You are a CRM data quality expert. Resolve this field conflict:

FIELD: ${fieldName}

HUBSPOT VALUE:
Value: ${hubspotRecord[fieldName]}
Last Modified: ${hubspotRecord.updatedAt}
Modified By: ${hubspotRecord.updatedBy || 'Unknown'}
Source: ${hubspotRecord.source || 'Unknown'}

SALESFORCE VALUE:
Value: ${salesforceRecord[fieldName]}
Last Modified: ${salesforceRecord.LastModifiedDate}
Modified By: ${salesforceRecord.LastModifiedById || 'Unknown'}

BUSINESS RULES:
1. More recent updates generally win
2. Human edits beat automated updates
3. Sales-owned fields (title, phone) prefer Salesforce
4. Marketing-owned fields (lead source, score) prefer HubSpot
5. Email should match—if different, flag for review

OUTPUT FORMAT:
{
"winner": "hubspot" | "salesforce" | "review_needed",
"reason": "brief explanation",
"confidence": 0.0-1.0
}`;

const response = await codex.complete({
model: 'gpt-5.3-codex',
prompt,
max_tokens: 200,
response_format: { type: 'json_object' }
});

return JSON.parse(response.choices[0].text);
}

async function resolveRecordConflicts(hsRecord, sfRecord, mapping) {
const resolutions = {};
const reviewNeeded = [];

for (const [hsField, sfConfig] of Object.entries(mapping)) {
const sfField = typeof sfConfig === 'string' ? sfConfig : sfConfig.salesforce_field;

const hsValue = hsRecord.properties[hsField];
const sfValue = sfRecord[sfField];

// Skip if same
if (hsValue === sfValue) {
resolutions[hsField] = { value: hsValue, source: 'same' };
continue;
}

// Skip if one is empty
if (!hsValue && sfValue) {
resolutions[hsField] = { value: sfValue, source: 'salesforce' };
continue;
}
if (hsValue && !sfValue) {
resolutions[hsField] = { value: hsValue, source: 'hubspot' };
continue;
}

// Conflict! Let Codex decide
const resolution = await resolveConflict(
{ [hsField]: hsValue, updatedAt: hsRecord.updatedAt },
{ [sfField]: sfValue, LastModifiedDate: sfRecord.LastModifiedDate },
hsField
);

if (resolution.winner === 'review_needed') {
reviewNeeded.push({
field: hsField,
hubspotValue: hsValue,
salesforceValue: sfValue,
reason: resolution.reason
});
} else {
resolutions[hsField] = {
value: resolution.winner === 'hubspot' ? hsValue : sfValue,
source: resolution.winner,
confidence: resolution.confidence,
reason: resolution.reason
};
}
}

return { resolutions, reviewNeeded };
}

module.exports = { resolveConflict, resolveRecordConflicts };

Step 4: Webhook Handlers

Set up webhooks for real-time sync:

// webhooks/hubspot.js
const express = require('express');
const { SyncEngine } = require('../lib/sync-engine');
const { resolveRecordConflicts } = require('../lib/conflict-resolver');

const router = express.Router();
const syncEngine = new SyncEngine();

router.post('/hubspot', async (req, res) => {
const events = req.body;

for (const event of events) {
const { subscriptionType, objectId, propertyName, propertyValue } = event;

// Handle contact updates
if (subscriptionType === 'contact.propertyChange') {
console.log(`📥 HubSpot contact ${objectId} updated: ${propertyName}`);

try {
await syncEngine.syncHubSpotToSalesforce('contact', objectId);
console.log(`✅ Synced to Salesforce`);
} catch (error) {
console.error(`❌ Sync failed: ${error.message}`);
// Queue for retry
await retryQueue.add({ type: 'contact', id: objectId, source: 'hubspot' });
}
}

// Handle deal updates
if (subscriptionType === 'deal.propertyChange') {
console.log(`📥 HubSpot deal ${objectId} updated: ${propertyName}`);
await syncEngine.syncHubSpotToSalesforce('deal', objectId);
}
}

res.sendStatus(200);
});

module.exports = router;
// webhooks/salesforce.js
const express = require('express');
const { SyncEngine } = require('../lib/sync-engine');

const router = express.Router();
const syncEngine = new SyncEngine();

// Salesforce Outbound Message handler
router.post('/salesforce', async (req, res) => {
// Parse SOAP envelope (Salesforce uses SOAP for outbound messages)
const notification = parseOutboundMessage(req.body);

const { objectType, recordId, changedFields } = notification;

console.log(`📥 Salesforce ${objectType} ${recordId} updated`);

try {
await syncEngine.syncSalesforceToHubSpot(objectType, recordId);
console.log(`✅ Synced to HubSpot`);
} catch (error) {
console.error(`❌ Sync failed: ${error.message}`);
}

// Return ACK
res.type('text/xml').send(`
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body>
<notificationsResponse xmlns="http://soap.sforce.com/2005/09/outbound">
<Ack>true</Ack>
</notificationsResponse>
</soapenv:Body>
</soapenv:Envelope>
`);
});

module.exports = router;

Step 5: Sync Dashboard

Build a simple UI to monitor syncs:

// api/sync-status.js
router.get('/status', async (req, res) => {
const stats = await db.query(`
SELECT
object_type,
COUNT(*) as total_synced,
COUNT(CASE WHEN status = 'success' THEN 1 END) as successful,
COUNT(CASE WHEN status = 'failed' THEN 1 END) as failed,
COUNT(CASE WHEN status = 'pending_review' THEN 1 END) as pending_review,
MAX(synced_at) as last_sync
FROM sync_log
WHERE synced_at > NOW() - INTERVAL '24 hours'
GROUP BY object_type
`);

const conflicts = await db.query(`
SELECT *
FROM sync_conflicts
WHERE resolved = false
ORDER BY created_at DESC
LIMIT 50
`);

res.json({
stats,
conflicts,
health: conflicts.length === 0 ? 'healthy' : 'needs_attention'
});
});

Cost comparison: DIY sync vs enterprise iPaaS tools

Cost Comparison

SolutionMonthly CostFeatures
Native HubSpot-SF SyncIncludedBasic, one-way many fields
Workato$2,000-5,000Full iPaaS, complex workflows
Tray.io$1,500-3,000Visual builder, good for non-devs
Syncari$1,000-2,500CRM-focused, good conflict resolution
DIY with Codex$50-150Full control, AI conflict resolution

The DIY approach costs ~95% less while giving you MORE intelligence in conflict resolution.

When This Makes Sense

Build your own when:

  • You have engineering resources
  • Your sync logic is complex/custom
  • Cost is a factor
  • You want full control and ownership

Use enterprise tools when:

  • No engineering bandwidth
  • Need compliance certifications
  • Want vendor support
  • Syncing many systems (not just 2)

Mid-Turn Steering for Bulk Syncs

When doing initial bulk sync (thousands of records), use Codex's mid-turn steering:

# Start bulk sync
codex run bulk-sync --source hubspot --target salesforce --object contacts

# Mid-run, adjust conflict rules
codex steer "For company size conflicts, prefer Salesforce values since sales updates those"

# Or pause problematic records
codex steer "Skip records from [email protected] domain - those are test records"

This lets you fine-tune the sync without starting over.

Common Gotchas

  1. Infinite loops — Mark records as "sync in progress" to prevent HubSpot→SF→HubSpot cycles
  2. Rate limits — Both APIs have limits; implement exponential backoff
  3. Timezone hell — Store all dates in UTC, convert on display
  4. Lookup fields — Sync Account before Contact (parent before child)
  5. Deleted records — Decide: soft delete or hard sync?

Want Unified Data Without the Sync Headaches?

MarketBetter aggregates signals from across your stack—CRM, website, email, intent—into one unified view. No more wondering which system has the truth.

Book a demo →


Related reading: