Playbook
The Salesforce sync that doesn't break.
Bidirectional HubSpot to Salesforce sync with conflict resolution, retries, and an audit trail. Survives rate limits, write storms, and network blips.
12 min read
The pain
Why your current sync breaks
Every GTM team running both HubSpot and Salesforce has the same problem. Sales updates a deal in Salesforce. Marketing updates the same contact in HubSpot. Both sides claim to be the source of truth. Whichever wrote last wins. The other write is silently lost.
The native connector hides this. Your custom cron job hides it harder.
The four ways a sync usually breaks:
- Rate limits. Salesforce throttles at 100 API calls per second. HubSpot Standard throttles at 10. Your sync hits the cap on a busy Tuesday and silently drops writes.
- Write storms. A bulk import on one side fans out 10,000 webhook events. Your sync tries to process them all in parallel. Half fail. Half corrupt.
- Conflicts. Both sides updated the same record in the same hour. Whose write wins? Most syncs answer "the one that arrived last." That answer is wrong.
- Network blips. Salesforce times out for 30 seconds on a Friday afternoon. Your sync logs an error. Nobody reads the log. The contact is now wrong in one system for a week.
The architecture
What we're building
A workflow that runs every 5 minutes, bidirectionally syncs changed records, and survives all four failure modes.
Three core principles:
- Source of truth per field type. HubSpot owns marketing fields (lifecycle stage, lead score, last form submission). Salesforce owns sales fields (deal stage, owner, close date). Conflicts are resolved by which system owns the field, not by timestamp.
- Idempotency keyed per attempt. Every write to either system carries a unique key. Replays are safe. Duplicates are impossible.
- Each API call is a step. Fetch HubSpot updates, fetch Salesforce state, and write back are separate steps. If Salesforce times out on the write, only the write retries — the two reads already completed and don't burn API quota again.
The workflow shape:
workflow({
id: "hubspot-salesforce-sync",
trigger: { schedule: "*/5 * * * *" }, // every 5 minutes
concurrency: { limit: 1 }, // one sync at a time, no overlap
steps: async ({ step, logger }) => {
const since = await step.run("get-last-sync-time", () => getLastSyncTime());
const hubspotUpdates = await step.run("fetch-hubspot-updates", () =>
hubspot.contacts.list({ updated: { gt: since }, limit: 200 })
);
for (const contact of hubspotUpdates) {
await syncContactToSalesforce(step, contact);
}
await step.run("set-last-sync-time", () => setLastSyncTime(new Date()));
}
});The implementation
Sync one contact, end to end
The function called per contact. Five steps, each independently retriable.
async function syncContactToSalesforce(step, hubspotContact) {
const email = hubspotContact.email;
// Step 1: find the matching Salesforce record
const sfRecord = await step.run(`find-sf-${email}`, () =>
salesforce.contacts.find({ email })
);
// Step 2: resolve conflicts using SoT rules
const merged = await step.run(`merge-${email}`, () => {
if (!sfRecord) {
// New contact — write everything to Salesforce
return { ...hubspotContact, _action: "create" };
}
return {
// Marketing fields — HubSpot wins
lifecycle_stage: hubspotContact.lifecycle_stage,
lead_score: hubspotContact.lead_score,
last_form_submission: hubspotContact.last_form_submission,
// Sales fields — Salesforce wins (don't touch them)
// owner_id, deal_stage, close_date are NOT in this object
// Identity fields — most-recent-update wins
first_name: pickRecent(hubspotContact, sfRecord, "first_name"),
last_name: pickRecent(hubspotContact, sfRecord, "last_name"),
phone: pickRecent(hubspotContact, sfRecord, "phone"),
_action: "update",
_id: sfRecord.id,
};
});
// Step 3: write to Salesforce, idempotent on (email, hubspot_updated_at)
const idempotencyKey = `hs-sf-${email}-${hubspotContact.updated_at}`;
await step.run(`write-sf-${email}`, () =>
salesforce.contacts.upsert(merged, { idempotencyKey })
);
// Step 4: log to audit table
await step.run(`audit-${email}`, () =>
db.syncAudit.insert({
email,
direction: "hubspot-to-salesforce",
action: merged._action,
hubspot_updated_at: hubspotContact.updated_at,
synced_at: new Date(),
})
);
}Five things this does that a cron + script doesn't:
- Each
step.runis independently retriable. If Salesforce write times out, only the write retries — the lookup and merge are memoized. - The idempotency key on the Salesforce write means a retry never creates a duplicate, even if the first call partially succeeded.
- The audit table is the audit table. Every write is logged. Every replay is logged. Compliance happy.
- Source-of-truth rules are explicit in code. A new GTM engineer can read this in 10 minutes and understand the conflict policy.
concurrency.limit = 1prevents two syncs from running at once. No write storms. No race conditions.
Edge cases
What goes wrong, and how to handle it
Both sides updated in the same window. Surface the conflict to Slack instead of guessing. Add a fifth step that posts to a #sync-conflicts channel when both hubspot.updated_at and salesforce.updated_at changed since last sync. Let a human resolve it.
Rate limits. Add a concurrency key so syncs for different teams don't compete:
concurrency: [
{ limit: 1, key: "event.data.team_id" }, // one sync per team
{ scope: "account", key: "salesforce", limit: 50 } // 50 SF calls in flight max
]Salesforce timeout mid-write. The default retry handles transient errors. For permanent errors (validation failure, field missing), throw a NonRetriable error and route to a dead-letter queue with the audit row.
Schema drift. If HubSpot adds a new field your merge function doesn't know about, log a warning and continue. Don't fail the sync. Add a daily cron that diffs HubSpot and Salesforce schemas and posts new fields to Slack.
Deleted records. Don't hard delete. Add a archived = true field on both sides and let the sync update it. Hard deletes are how you lose data forever.
The math
What this costs on Rotor
The workflow runs every 5 minutes (288 times a day). Each run averages 50 contacts updated. Each contact = 4 step-runs.
288 runs × 50 contacts × 4 steps = 57,600 step-runs/day. Outliers spike to 2x. Steady state lands at ~60k/day.
That fits Rotor Pro ($99/mo, 100k step-runs included). Same workflow on:
- Zapier: 60k tasks/day = 1.8M/month = $400+/mo on Professional + tasks
- n8n Cloud: 60k executions/day if you treat each contact as one execution = ~$200/mo on Pro tier with custom volume
- Building it yourself: free in compute, plus your hourly rate every time it breaks
The "build it yourself" option is the most expensive one. Your time is not free. Cron does not retry, has no audit trail, and breaks silently. Rotor Pro at $99/mo is cheaper than one hour of your engineer's time spent debugging at 3am.
Fork this playbook on Rotor.
$9 to start. 30-day money back. Hard caps protect you from runaway bills.
Start shipping