Manage bidirectional HubSpot-Salesforce sync using a Claude Code skill

medium complexityCost: Usage-based

Prerequisites

Compatible agents

This skill works with any agent that supports the Claude Code skills standard, including Claude Code, Claude Cowork, OpenAI Codex, and Google Antigravity.

Prerequisites
  • One of the agents listed above
  • HubSpot private app with crm.objects.contacts.read, crm.objects.contacts.write, crm.objects.companies.read, crm.objects.companies.write, crm.objects.deals.read, and crm.objects.deals.write scopes
  • Salesforce connected app with API access to Contact, Lead, Account, and Opportunity objects
Environment Variables
# HubSpot private app token (Settings > Integrations > Private Apps)
HUBSPOT_ACCESS_TOKEN=your_value_here
# Salesforce OAuth access token (Connected App > OAuth flow)
SALESFORCE_ACCESS_TOKEN=your_value_here
# Your Salesforce instance URL (e.g., https://yourorg.my.salesforce.com)
SALESFORCE_INSTANCE_URL=your_value_here
# Slack bot token starting with xoxb- for sync reports (optional)
SLACK_BOT_TOKEN=your_value_here
# Slack channel ID for sync reports (optional)
SLACK_CHANNEL_ID=your_value_here

Why a Claude Code skill?

This approach is not a replacement for real-time sync. The native HubSpot integration and n8n handle continuous synchronization. A Claude Code skill is the complement — the Swiss Army knife you reach for when you need to audit sync health, investigate mismatches, or fix drift that the native sync missed.

That means you can say:

  • "Audit contacts — show me which records are out of sync between HubSpot and Salesforce"
  • "Sync the last 48 hours of contact changes from HubSpot to Salesforce"
  • "What fields are mismatched for the Acme Corp account?"
  • "Fix all contacts where the phone number differs between the two systems"

The skill contains workflow guidelines, API reference files for both CRMs, and a field mapping template that the agent reads on demand. When you invoke the skill, Claude reads these files, writes a script that queries both systems, compares records, and either reports mismatches or applies fixes. If you ask for something different — a specific company, a different object type, a summary instead of remediation — the agent adapts without you touching any code.

How it works

The skill directory has three parts:

  1. SKILL.md — workflow guidelines telling the agent how to audit, compare, and remediate records across both CRMs, which env vars to use, and what pitfalls to avoid
  2. references/ — API patterns for both HubSpot (CRM search, contact/company/deal endpoints) and Salesforce (SOQL queries, REST endpoints, record updates) so the agent calls the right APIs with the right parameters
  3. templates/ — a field mapping table that defines which HubSpot properties correspond to which Salesforce fields for contacts, companies, and deals

When invoked, the agent reads SKILL.md, consults the reference and template files as needed, writes a Python script that fetches records from both systems, compares field values, and either reports mismatches or pushes fixes. The field mapping template is the key guardrail — it tells the agent exactly which fields to compare and which system's value to trust when they differ.

What is a Claude Code skill?

An Claude Code skill is a reusable command you add to your project that Claude Code can run on demand. Skills live in a .claude/skills/ directory and are defined by a SKILL.md file that tells the agent what the skill does, when to run it, and what tools it's allowed to use.

In this skill, the agent doesn't run a pre-written script. Instead, SKILL.md provides workflow guidelines and points to reference files — API documentation for both HubSpot and Salesforce, plus a field mapping template — that the agent reads to generate and execute code itself. This is the key difference from a traditional script: the agent can adapt its approach based on what you ask for while still using the right APIs and field mappings.

Once installed, you can invoke a skill as a slash command (e.g., /crm-sync), or the agent will use it automatically when you give it a task where the skill is relevant. Skills are portable — anyone who clones your repo gets the same commands.

Step 1: Create the skill directory

mkdir -p .claude/skills/crm-sync/{templates,references}

This creates the layout:

.claude/skills/crm-sync/
├── SKILL.md                            # workflow guidelines + config
├── templates/
│   └── field-mapping.md                # HubSpot ↔ Salesforce field mapping
└── references/
    ├── hubspot-crm-api.md              # HubSpot API patterns
    └── salesforce-rest-api.md          # Salesforce API patterns

Step 2: Write the SKILL.md

Create .claude/skills/crm-sync/SKILL.md:

---
name: crm-sync
description: Audit and remediate sync mismatches between HubSpot and Salesforce. Compares contacts, companies, and deals across both CRMs, reports discrepancies, and optionally pushes fixes.
disable-model-invocation: true
allowed-tools: Bash, Read, Grep
---
 
## Goal
 
Audit and remediate sync mismatches between HubSpot and Salesforce. The user may ask for a full audit, a targeted comparison of specific records, or a fix for known mismatches. Adapt the workflow based on what they request.
 
## Configuration
 
Read these environment variables:
 
- `HUBSPOT_ACCESS_TOKEN` — HubSpot private app token (required)
- `SALESFORCE_ACCESS_TOKEN` — Salesforce OAuth access token (required)
- `SALESFORCE_INSTANCE_URL` — Salesforce instance URL, e.g. https://yourorg.my.salesforce.com (required)
- `SLACK_BOT_TOKEN` — Slack bot token starting with xoxb- (optional — for posting sync reports)
- `SLACK_CHANNEL_ID` — Slack channel ID starting with C (optional — required if SLACK_BOT_TOKEN is set)
 
Default scope: contacts. The user may request companies or deals instead. Default lookback: 7 days for audit, all records for targeted comparisons.
 
## Workflow
 
1. Validate that all required env vars are set. If any are missing, print which ones and exit.
2. Determine user intent:
   - **Audit** — scan for mismatches across a set of records
   - **Sync** — push recent changes from one system to the other
   - **Fix** — remediate specific mismatched records
3. Fetch records from HubSpot using the CRM Search API. See `references/hubspot-crm-api.md` for endpoint details and pagination.
4. Fetch corresponding records from Salesforce using SOQL queries. See `references/salesforce-rest-api.md` for query syntax and response shapes.
5. Match records across systems using email address (contacts), domain (companies), or deal name + amount (deals).
6. Compare field values using the mapping in `templates/field-mapping.md`. For each matched pair, check every mapped field for differences.
7. Based on user intent:
   - **Audit**: Print a summary table of mismatched records and fields. Group by object type.
   - **Sync**: Push the newer value (based on timestamps) to the outdated system. Confirm before writing.
   - **Fix**: Update the specified records in the target system. Confirm before writing.
8. If `SLACK_BOT_TOKEN` and `SLACK_CHANNEL_ID` are set, post a summary report to Slack with mismatch counts and actions taken.
9. Print a final summary with record counts, mismatch counts, and any errors encountered.
 
## Important notes
 
- HubSpot timestamps (`hs_lastmodifieddate`) are in **milliseconds** since epoch. Salesforce timestamps (`SystemModstamp`) are in ISO 8601 format. Convert both to UTC datetime objects before comparing.
- HubSpot rate limits: 100 requests per 10 seconds for private apps. Batch requests where possible. Add 100ms delays between pages.
- Salesforce rate limits vary by edition — typically 15,000-100,000 requests per 24 hours. Use SOQL bulk queries to minimize API calls.
- HubSpot contacts match to Salesforce Contacts or Leads (check both). HubSpot companies match to Salesforce Accounts. HubSpot deals match to Salesforce Opportunities.
- Email matching is case-insensitive. Normalize both sides to lowercase before comparing.
- When fixing records, NEVER overwrite a field in one system without logging what the previous value was. Print a before/after table for every change.
- Use the `requests` library for HTTP calls. Install with pip if needed.
- `SLACK_CHANNEL_ID` must be the channel ID (starts with `C`), not the channel name. The Slack bot must be invited to the target channel.

Step 3: Add reference files

references/hubspot-crm-api.md

Create .claude/skills/crm-sync/references/hubspot-crm-api.md:

# HubSpot CRM API Reference
 
## Authentication
 
All requests use Bearer token authentication:
```
Authorization: Bearer <HUBSPOT_ACCESS_TOKEN>
Content-Type: application/json
```
 
## Search contacts
 
**Request:**
 
```
POST https://api.hubapi.com/crm/v3/objects/contacts/search
```
 
**Body:**
 
```json
{
  "filterGroups": [
    {
      "filters": [
        {
          "propertyName": "hs_lastmodifieddate",
          "operator": "GTE",
          "value": "<cutoff_timestamp_ms>"
        }
      ]
    }
  ],
  "properties": ["email", "firstname", "lastname", "phone", "jobtitle", "company", "lifecyclestage", "hs_lastmodifieddate"],
  "limit": 100
}
```
 
- `value` for date filters is a Unix timestamp in **milliseconds**.
- `limit` max is 100. Paginate using `paging.next.after` if more results exist.
 
**Response shape:**
 
```json
{
  "total": 45,
  "results": [
    {
      "id": "101",
      "properties": {
        "email": "laura.chen@meridian.com",
        "firstname": "Laura",
        "lastname": "Chen",
        "phone": "(555) 987-6543",
        "jobtitle": "VP Operations",
        "company": "Meridian Logistics",
        "lifecyclestage": "customer",
        "hs_lastmodifieddate": "1741248840000"
      }
    }
  ],
  "paging": {
    "next": { "after": "100" }
  }
}
```
 
## Get a single contact
 
**Request:**
 
```
GET https://api.hubapi.com/crm/v3/objects/contacts/{contactId}?properties=email,firstname,lastname,phone,jobtitle,company,lifecyclestage,hs_lastmodifieddate
```
 
## Search companies
 
**Request:**
 
```
POST https://api.hubapi.com/crm/v3/objects/companies/search
```
 
**Body:**
 
```json
{
  "filterGroups": [
    {
      "filters": [
        {
          "propertyName": "hs_lastmodifieddate",
          "operator": "GTE",
          "value": "<cutoff_timestamp_ms>"
        }
      ]
    }
  ],
  "properties": ["name", "domain", "industry", "phone", "city", "state", "annualrevenue", "numberofemployees", "hs_lastmodifieddate"],
  "limit": 100
}
```
 
## Search deals
 
**Request:**
 
```
POST https://api.hubapi.com/crm/v3/objects/deals/search
```
 
**Body:**
 
```json
{
  "filterGroups": [
    {
      "filters": [
        {
          "propertyName": "hs_lastmodifieddate",
          "operator": "GTE",
          "value": "<cutoff_timestamp_ms>"
        }
      ]
    }
  ],
  "properties": ["dealname", "amount", "closedate", "dealstage", "pipeline", "hubspot_owner_id", "hs_lastmodifieddate"],
  "limit": 100
}
```
 
## Update a contact
 
**Request:**
 
```
PATCH https://api.hubapi.com/crm/v3/objects/contacts/{contactId}
```
 
**Body:**
 
```json
{
  "properties": {
    "phone": "(555) 987-6543",
    "jobtitle": "VP Operations"
  }
}
```
 
## Update a company
 
**Request:**
 
```
PATCH https://api.hubapi.com/crm/v3/objects/companies/{companyId}
```
 
## Update a deal
 
**Request:**
 
```
PATCH https://api.hubapi.com/crm/v3/objects/deals/{dealId}
```
 
## Notes
 
- Pagination: continue fetching with `after` value from `paging.next.after` until `paging` is absent.
- Rate limit: 100 requests per 10 seconds. Add 100ms delays between paginated requests.
- All timestamps in properties are milliseconds since epoch (string format in responses).
- Empty properties are returned as `null`, not omitted.

references/salesforce-rest-api.md

Create .claude/skills/crm-sync/references/salesforce-rest-api.md:

# Salesforce REST API Reference
 
## Authentication
 
All requests use Bearer token authentication:
```
Authorization: Bearer <SALESFORCE_ACCESS_TOKEN>
```
 
Base URL: `<SALESFORCE_INSTANCE_URL>/services/data/v59.0`
 
## Query contacts
 
**Request:**
 
```
GET <SALESFORCE_INSTANCE_URL>/services/data/v59.0/query?q=<SOQL>
```
 
**SOQL:**
 
```sql
SELECT Id, Email, FirstName, LastName, Phone, Title, Account.Name,
       LeadSource, SystemModstamp
FROM Contact
WHERE SystemModstamp > 2026-02-27T00:00:00Z
ORDER BY SystemModstamp DESC
```
 
**Response shape:**
 
```json
{
  "totalSize": 32,
  "done": true,
  "records": [
    {
      "Id": "003XXXXXXXXXXXXXXX",
      "Email": "laura.chen@meridian.com",
      "FirstName": "Laura",
      "LastName": "Chen",
      "Phone": "(555) 987-6543",
      "Title": "VP Operations",
      "Account": { "Name": "Meridian Logistics" },
      "LeadSource": "Inbound",
      "SystemModstamp": "2026-03-05T14:30:00.000+0000"
    }
  ]
}
```
 
## Query leads
 
Some HubSpot contacts may map to Salesforce Leads instead of Contacts:
 
```sql
SELECT Id, Email, FirstName, LastName, Phone, Title, Company,
       LeadSource, SystemModstamp
FROM Lead
WHERE SystemModstamp > 2026-02-27T00:00:00Z AND IsConverted = false
ORDER BY SystemModstamp DESC
```
 
## Query accounts
 
```sql
SELECT Id, Name, Website, Industry, Phone, BillingCity, BillingState,
       AnnualRevenue, NumberOfEmployees, SystemModstamp
FROM Account
WHERE SystemModstamp > 2026-02-27T00:00:00Z
ORDER BY SystemModstamp DESC
```
 
- Match HubSpot companies to Salesforce Accounts by domain. Extract domain from `Website` field (strip protocol and path).
 
## Query opportunities
 
```sql
SELECT Id, Name, Amount, CloseDate, StageName, Account.Name,
       Owner.Name, SystemModstamp
FROM Opportunity
WHERE SystemModstamp > 2026-02-27T00:00:00Z
ORDER BY SystemModstamp DESC
```
 
## Update a record
 
**Request:**
 
```
PATCH <SALESFORCE_INSTANCE_URL>/services/data/v59.0/sobjects/{ObjectType}/{RecordId}
Content-Type: application/json
Authorization: Bearer <SALESFORCE_ACCESS_TOKEN>
```
 
**Body:**
 
```json
{
  "Phone": "(555) 987-6543",
  "Title": "VP Operations"
}
```
 
Returns `204 No Content` on success.
 
## Token refresh
 
If the access token expires (401 INVALID_SESSION_ID), use the refresh token:
 
```
POST https://login.salesforce.com/services/oauth2/token
Content-Type: application/x-www-form-urlencoded
 
grant_type=refresh_token&client_id=<CLIENT_ID>&client_secret=<CLIENT_SECRET>&refresh_token=<REFRESH_TOKEN>
```
 
**Response:**
 
```json
{
  "access_token": "00DXXXXXXXXXXXXXXX!...",
  "instance_url": "https://yourorg.my.salesforce.com"
}
```
 
## Notes
 
- URL-encode SOQL queries when passing as the `q` parameter.
- Pagination: if `done` is `false`, follow `nextRecordsUrl` to fetch remaining records.
- `SystemModstamp` is the most reliable "last modified" timestamp. It updates on any field change including system-triggered updates.
- Salesforce timestamps are ISO 8601 format with timezone offset.
- SOQL date literals: `LAST_N_DAYS:7`, `LAST_N_HOURS:24`, `TODAY`, `THIS_WEEK`.
- Rate limits vary by edition. Enterprise = 100,000 requests/24 hours. Professional = 15,000/24 hours.
- A `204 No Content` response on PATCH means success. No body is returned.

Step 4: Add the field mapping template

Create .claude/skills/crm-sync/templates/field-mapping.md:

# HubSpot ↔ Salesforce Field Mapping
 
## Contacts
 
| HubSpot Property | Salesforce Field (Contact) | Salesforce Field (Lead) | Match Key |
|---|---|---|---|
| email | Email | Email | Yes — primary match key (case-insensitive) |
| firstname | FirstName | FirstName | |
| lastname | LastName | LastName | |
| phone | Phone | Phone | |
| jobtitle | Title | Title | |
| company | Account.Name | Company | |
| lifecyclestage | LeadSource (mapped) | LeadSource (mapped) | |
 
## Companies / Accounts
 
| HubSpot Property | Salesforce Field (Account) | Match Key |
|---|---|---|
| domain | Website (extract domain) | Yes — primary match key |
| name | Name | |
| industry | Industry | |
| phone | Phone | |
| city | BillingCity | |
| state | BillingState | |
| annualrevenue | AnnualRevenue | |
| numberofemployees | NumberOfEmployees | |
 
## Deals / Opportunities
 
| HubSpot Property | Salesforce Field (Opportunity) | Match Key |
|---|---|---|
| dealname | Name | Yes — match on name + amount |
| amount | Amount | Yes — secondary match key |
| closedate | CloseDate | |
| dealstage | StageName (mapped) | |
| pipeline | (no direct equivalent) | |
 
## Comparison rules
 
1. Match records using the designated match key (email for contacts, domain for companies, name+amount for deals)
2. For each matched pair, compare every mapped field
3. Normalize before comparing: lowercase emails, strip phone formatting, trim whitespace
4. Treat null/empty as equivalent — don't flag a mismatch if one side is null and the other is an empty string
5. For timestamp comparison, use `hs_lastmodifieddate` (HubSpot, milliseconds) vs `SystemModstamp` (Salesforce, ISO 8601). The more recent timestamp indicates the authoritative value.
 
## Stage mapping (deals ↔ opportunities)
 
HubSpot and Salesforce use different stage names. Common mappings:
 
| HubSpot Stage | Salesforce Stage |
|---|---|
| appointmentscheduled | Qualification |
| qualifiedtobuy | Needs Analysis |
| presentationscheduled | Proposal/Price Quote |
| decisionmakerboughtin | Perception Analysis |
| contractsent | Negotiation/Review |
| closedwon | Closed Won |
| closedlost | Closed Lost |
 
These are defaults. Update this table to match your pipeline configuration.

Step 5: Test the skill

Invoke the skill conversationally:

/crm-sync

Start with a targeted audit to verify the setup:

"Audit contacts — show me which records are out of sync between HubSpot and Salesforce. Limit to 50 contacts modified in the last 7 days."

The agent will read the SKILL.md, fetch records from both systems, compare field values, and report mismatches. A typical audit run looks like:

Validating environment variables... all set.
Fetching HubSpot contacts modified in the last 7 days...
  Found 47 contacts
Fetching Salesforce contacts and leads...
  Found 52 contacts, 11 leads
Matching records by email...
  Matched: 41 pairs
  HubSpot-only: 6 (no Salesforce match)
  Salesforce-only: 17 (no HubSpot match)
 
Mismatches found: 12 of 41 matched records
 
| Email                        | Field     | HubSpot Value     | Salesforce Value   |
|------------------------------|-----------|-------------------|--------------------|
| laura.chen@meridian.com      | phone     | (555) 987-6543    | (555) 012-3456     |
| laura.chen@meridian.com      | jobtitle  | VP Operations     | Director of Ops    |
| james.okoro@pinnacle.io      | company   | Pinnacle Health   | Pinnacle Health Inc|
| ...                          | ...       | ...               | ...                |
 
Summary: 12 mismatched records, 23 mismatched fields total.

Because the agent generates code on the fly, you can follow up with targeted requests:

  • "Fix all contacts where the phone number differs — use the most recently updated value" — the agent compares timestamps and pushes the newer value
  • "What fields are mismatched for the Acme Corp account?" — the agent narrows to a single company
  • "Sync the last 48 hours of deal changes from Salesforce to HubSpot" — the agent changes direction and scope
  • "Post the mismatch report to #rev-ops in Slack" — the agent formats and sends a summary
Start with audit, then fix

Run an audit first to understand the scope of drift. Review the mismatches before asking the agent to fix anything. The agent will always confirm before writing to either system, but starting read-only gives you a clear picture of what will change.

Step 6: Schedule it (optional)

Option A: Cron + Claude CLI

# Run a sync audit every morning at 8 AM
0 8 * * * cd /path/to/your/project && claude -p "Run /crm-sync — audit all contacts modified in the last 24 hours and post the report to Slack" --allowedTools 'Bash(*)' 'Read(*)'

Option B: GitHub Actions + Claude

name: CRM Sync Audit
on:
  schedule:
    - cron: '0 13 * * 1-5'  # 8 AM ET, weekdays
  workflow_dispatch: {}
jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: anthropics/claude-code-action@v1
        with:
          prompt: "Run /crm-sync — audit contacts, companies, and deals modified in the last 24 hours. Post the mismatch report to Slack."
          allowed_tools: "Bash(*),Read(*)"
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          HUBSPOT_ACCESS_TOKEN: ${{ secrets.HUBSPOT_ACCESS_TOKEN }}
          SALESFORCE_ACCESS_TOKEN: ${{ secrets.SALESFORCE_ACCESS_TOKEN }}
          SALESFORCE_INSTANCE_URL: ${{ secrets.SALESFORCE_INSTANCE_URL }}
          SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
          SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }}
Salesforce token expiration on GitHub Actions

Salesforce access tokens expire based on your org's session timeout settings (default: 2 hours). For scheduled automation, use a Connected App with JWT bearer flow to obtain fresh tokens automatically. A static access token will stop working after the session expires. The Salesforce reference file documents the token refresh endpoint.

Option C: Cowork Scheduled Tasks

Claude Desktop's Cowork supports built-in scheduled tasks. Open a Cowork session, type /schedule, and configure the cadence — hourly, daily, weekly, or weekdays only. Each scheduled run has full access to your connected tools, plugins, and MCP servers.

Scheduled tasks only run while your computer is awake and Claude Desktop is open. If a run is missed, Cowork executes it automatically when the app reopens. For always-on scheduling, use GitHub Actions (Option B) instead. Available on all paid plans (Pro, Max, Team, Enterprise).

Troubleshooting

When to use this approach

  • You want to audit sync health — find which records drifted and which fields don't match
  • You need on-demand remediation — fix specific mismatches without waiting for the next sync cycle
  • You want to investigate sync issues — ask "why is Acme Corp's phone number different in both systems?"
  • You're already using Claude Code and want skills that integrate with your workflow
  • You want to run tasks in the background via Claude Cowork while focusing on other work
  • You need a complement to the native integration — the native sync handles the steady state, and this skill handles the exceptions

When to switch approaches

  • You need continuous real-time sync → use the HubSpot native integration or n8n
  • You want a no-code setup with a visual builder → use the native HubSpot-Salesforce connector
  • You need sync running 24/7 with zero LLM cost → use n8n self-hosted or the native integration

Common questions

Can this replace real-time sync?

No. This skill runs on-demand or on a schedule. For real-time sync, use the native HubSpot integration or n8n. This skill is best as a complement — auditing sync health, fixing drift, and handling edge cases the native sync misses. Think of it as the monitoring layer and the manual override, not the sync engine.

How do I handle Salesforce token refresh?

Add the refresh token flow to the Salesforce reference file (it's already documented there). The agent will use it automatically when the access token expires. Alternatively, generate a long-lived token via JWT Bearer flow for service-to-service auth — this is the recommended approach for scheduled runs on GitHub Actions.

Can I sync custom objects?

Yes. Add the custom object endpoints to the reference files and update the field mapping template with your custom object's field names. The agent can sync any object accessible via the HubSpot and Salesforce REST APIs. HubSpot custom object API names are available under Settings > Data Management > Custom Objects. Salesforce custom object names end with __c.

Cost

  • Claude API — $0.02-0.10 per invocation (heavier than single-system skills due to multi-system API calls and record comparison logic)
  • HubSpot API — included in all paid plans, no per-call cost
  • Salesforce API — included in Enterprise, Unlimited, and Developer editions
  • GitHub Actions (if scheduled) — free tier includes 2,000 minutes/month

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.