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.run is 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 = 1 prevents 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