Back to blog

CRM Integration Challenges: Exact Fixes in Salesforce and HubSpot

Eight CRM integration challenges and the exact fixes in Salesforce and HubSpot: ID case traps, upserts, validation rules, echo loops, merges and API limits.

Luka Abramovic18 min read

A CRM exchanging customer records with the systems that depend on them

The CRM integration challenges that cost the most come from platform behavior nobody reads about until production, and the first one can hide in a spreadsheet. Salesforce reports show 15-character record IDs, which are case-sensitive. Excel's VLOOKUP and XLOOKUP ignore case. Match two exports on those IDs and a lookup can quietly return another customer's row. Use the 18-character ID instead: ask your Salesforce admin to add a formula field containing CASESAFEID(Id), and match on that column.

Below are eight challenges in the order most teams meet them, each with the mechanism, how it shows up in Salesforce and in HubSpot, and what to check this week. Limits and licenses are as of September 2026. The last section is a worksheet to fill in with your CRM admin before anyone builds.

1. The systems disagree about who the customer is

An integration needs one key per customer that both systems hold and that never changes. Names and email addresses change, and the CRM's own record ID has traps of its own.

The case trap in Salesforce IDs

A Salesforce record ID has two forms. The 15-character form is case-sensitive, so 0015g00000AbCdE and 0015g00000AbCde are two different accounts. The 18-character form adds three characters that encode the capitalization of the first fifteen, so it stays unique in tools that ignore case. The API returns 18 characters. Reports display 15 (Salesforce Help). Excel lookups treat upper and lower case as equal (Microsoft's XLOOKUP reference), so a report export matched in a spreadsheet is exactly where this goes wrong.

To test an export you already have, paste the IDs into column A, put this formula in B2 and fill it down. Anyone comfortable with Excel can run it.

=SUMPRODUCT(--(UPPER($A$2:$A$5000)=UPPER(A2))) - SUMPRODUCT(--EXACT($A$2:$A$5000,A2))

It counts the other IDs in the list that match this one when case is ignored but differ when it isn't. Any result above zero means an ordinary lookup on that ID can return the wrong row. If you have to match on 15-character IDs, make the lookup case-sensitive with EXACT:

=XLOOKUP(TRUE, EXACT($A$2:$A$5000, F2), $B$2:$B$5000, "not found")

Store the other system's key, then upsert on it

Don't make the CRM's ID the only key. Store the ERP or billing system's customer number on the CRM record, in a field the platform treats as an identifier, and write with an upsert: one call that updates the record carrying that key, or creates it if none exists. Because the platform looks for the key before it writes, a retry after a lost response updates the customer instead of creating a second one.

In Salesforce, the admin creates a text field such as ERP_Customer_Id__c and ticks External ID and Unique. The integration then writes to the key, not to the Salesforce ID:

PATCH /services/data/vXX.X/sobjects/Account/ERP_Customer_Id__c/C-10442
{ "Name": "Acme Industrial", "BillingCity": "Dayton" }

A 201 means it created the account. An update returns a success code with created set to false. If more than one record carries C-10442, Salesforce returns a 300 with the list of matches and writes nothing (REST API guide). That 300 is what happens when nobody ticked Unique. For volume, sObject Collections upserts up to 200 records per request on the same key.

In HubSpot, the equivalent is a property with "Require unique values" switched on, written through the batch upsert endpoint with idProperty set to that property. Two limits matter before you build: an object can have at most ten unique-value properties, and uniqueness can only be set when the property is created (HubSpot Knowledge Base). If someone already created an "ERP ID" property without it, you'll need a new property and a backfill.

POST /crm/objects/2026-09/companies/batch/upsert
{ "inputs": [ { "idProperty": "erp_customer_id", "id": "C-10442",
                "properties": { "name": "Acme Industrial", "city": "Dayton" } } ] }

This week: ask your admin which field holds the other system's customer number today, and whether it is marked unique. If the answer is "the name", that is your first project.

2. "Required" means one thing to a person and another to the API

Salesforce has three ways to make a field required, and they don't apply to the same writers (Salesforce Help):

Where each kind of Salesforce "required" is enforced
SettingEnforced for people in the UIEnforced for the API
Required on the page layoutYesNo
Required on the field definition ("universally required")YesYes
Validation ruleYesYes, on every save

That produces two failures that look like bugs. First, the integration creates records a salesperson couldn't save. The page layout says Industry is required, the API ignores the layout, and hundreds of accounts arrive without it. The next person to edit one of them in the UI can't save their change until they fill in Industry, and they blame the integration.

Second, the integration fails on rules nobody told the developer about. Salesforce applies validation rules to every new and updated record, even when the fields the rule checks aren't in the API call (Validation Rule Considerations). A rule that says "Close Date can't be in the past" will reject the integration's update of an unrelated field, such as invoice status, on every old opportunity. Written as AND(ISCHANGED(CloseDate), CloseDate < TODAY()), the same rule checks only when someone changes the date.

In HubSpot, properties marked required on an object's create form apply when people create or edit records by hand. HubSpot documents that they don't apply to other tools such as workflows (HubSpot Knowledge Base), so treat them as a UI rule and confirm how your integration's writes behave in a test account.

When the receiving system needs a field that the CRM doesn't guarantee, such as a delivery start date before a project can be created, enforce it in the CRM at the moment the handoff fires, with a validation rule rather than a layout setting. This one blocks a deal from being marked won without a start date, for people and for the API alike:

AND(
  ISPICKVAL(StageName, "Closed Won"),
  ISBLANK(Delivery_Start_Date__c)
)

This week: ask your admin for every active validation rule on the objects the integration writes, with its error message, and decide for each one whether it should apply to the integration. The admin can exempt the integration user from a rule without deleting it for everyone.

3. Two-way sync echoes every change back

Which way each field flows belongs in a field ownership table like the one in our integration guide. Once any field moves both ways, every write looks like a new change to the other side: the integration updates Salesforce, reads that update back as a change and sends it to the ERP, which reports a change of its own. At best that doubles your API usage. At worst, two systems that format a phone number differently overwrite each other forever.

Why "ignore my own changes" isn't enough

The obvious fix is to skip any record whose Last Modified By is the integration user. It has a hole. When the integration's write triggers a flow, Salesforce records the flow's field updates under the user who triggered it, which is the integration user (Salesforce Help). If a flow recalculates a synced field, such as region from the billing state, the filter throws that change away and the other system never hears about it.

Our default is to compare values rather than users. The integration keeps a fingerprint of the synced fields for each record, meaning only the fields it maps:

When the integration writes record R:
    fingerprint[R] = hash(the synced fields it just wrote)

When a change to record R is detected:
    current = hash(R's synced fields, read now)
    if current == fingerprint[R]: do nothing
        (its own echo, or a change to a field it doesn't sync)
    else: send the synced fields, then fingerprint[R] = current

This drops echoes and changes to fields nobody syncs, and it still passes on a flow's recalculation. Give every integration its own user anyway, so Last Modified By tells a person which integration touched a record.

Read changes by SystemModstamp

For incremental reads from Salesforce, filter on SystemModstamp, not LastModifiedDate. SystemModstamp also changes when Salesforce's own background processes update a record, such as a roll-up summary recalculation, while LastModifiedDate doesn't. SystemModstamp is also indexed (Salesforce Help). A developer or admin runs a query like this on each cycle, with the time of the last successful run:

SELECT Id, Name, BillingState, OwnerId, LastModifiedById, SystemModstamp
FROM Account
WHERE SystemModstamp > 2026-09-24T02:00:00Z
ORDER BY SystemModstamp

Our default is to start each window a few minutes before the last one ended. With upserts and fingerprints, re-reading a record costs nothing. Missing one does.

In HubSpot, every property change records a source. When an app made the change, the source type is INTEGRATION and the source ID is that app's ID, which you can see in the property history (HubSpot changelog). Property-change webhooks carry the change source too. Use it the same way as Last Modified By, as a hint, with the fingerprint as the rule.

4. Merges and deletes orphan records downstream

Someone merges two duplicate accounts. One ID disappears, and every invoice, project or ticket elsewhere that referenced it now points at nothing. Merges are routine cleanup, which is why they rarely reach a test plan.

Salesforce: merged-away records stay findable for 15 days

A Salesforce merge combines up to three records. It keeps one, reparents related records to it and deletes the others, stamping each deleted record's MasterRecordId with the ID of the survivor (Apex Developer Guide). Deleted records sit in the Recycle Bin for 15 days (Salesforce Help). Normal queries skip them, but queryAll returns them (REST API guide).

That gives you a repointing job. Once a night, list every merged-away account still in the Recycle Bin. An admin can run it with the Salesforce CLI:

sf data query --all-rows --result-format csv --output-file merged-accounts.csv \
  --query "SELECT Id, MasterRecordId, Name FROM Account WHERE IsDeleted = true AND MasterRecordId != null"

For each row, find the downstream records that still reference Id and change them to MasterRecordId. Follow chains: if A was merged into B and B later into C, A's MasterRecordId is B, which is itself deleted, so keep looking up until you reach a live record. Running it again changes nothing, so a missed night costs nothing. Missing fifteen nights does. After that the rows are purged and the link between the old ID and the survivor is gone.

HubSpot: a merge can change the surviving record's ID

HubSpot merges two records at a time. Unless your account is enrolled in the Primary ID Preservation public beta, the merged result gets a new record ID. The old IDs still resolve, so reading a record by an old ID returns the merged record, and the merged record's hs_merged_object_ids property lists every ID folded into it (HubSpot Knowledge Base, HubSpot changelog). The trap is that nothing fails. Reads keep working while your stored ID drifts from the record's real one, until a comparison between systems reports a customer that doesn't exist. Whenever a read returns a different ID from the one you asked for, update your stored mapping. Merge webhooks carry the same information as it happens, in newObjectId and mergedObjectIds.

Decide what a delete means before one happens

Our default: a CRM deletion never deletes anything in a finance system. A salesperson deleting a duplicate account should not remove invoices. Propagate a delete as a flag on the downstream record and a line in the recovery queue, and let a person decide.

5. A bulk change meets the API limits

Daily sync volume rarely hits a limit. A territory reassignment, an import or a cleanup does, on the day someone runs it.

Find your real Salesforce allocation

Your daily API request allocation depends on your edition and licenses, so read your own number rather than a published example. An admin can run sf org list limits in the Salesforce CLI, which shows the maximum and the remaining amount for each limit in the org (Salesforce CLI). Professional Edition doesn't include API access by default. It has to be bought as an add-on, which is worth confirming before any integration is quoted (Salesforce Help).

A worked example: reassigning 40,000 accounts

An illustrative example. A sales-ops lead reassigns 40,000 accounts to new owners. The org's daily allocation is 120,000 requests, and normal activity uses 70,000, leaving 50,000. The same change costs very different amounts depending on how it is sent:

Requests needed to update 40,000 Salesforce accounts (illustrative inputs)
MethodRequestsShare of the 50,000 left today
One record per REST call40,00080%
sObject Collections, 200 records per call2000.4%
Bulk API 2.0, one job (create, upload, close, poll, fetch results)About 10 to 20, depending on pollingUnder 0.1%

For a change this size, use Bulk API 2.0. Salesforce's own CLI steers any query over 10,000 records to it and describes its bulk update command as built for millions of records (Salesforce CLI). An admin can run the whole job from a CSV whose first column is the account ID:

sf data update bulk --sobject Account --file territory-change.csv --wait 30

The bigger cost usually comes one step later, in the integration. It now sees 40,000 changed accounts. If it pushes each one to the ERP and the ERP writes a "last synced" stamp back, that is 40,000 writes out and 40,000 updates back in, and each of those looks like a new change. The fingerprint from challenge 3 stops all of it: owner isn't a synced field, so the fingerprints match and nothing moves. If the ERP does need the owner, for commission for example, the integration has to batch too.

HubSpot: read the 429 before you retry

HubSpot limits requests per ten seconds and per day, at levels that depend on the type of app and on your account's subscription and add-ons. When you exceed one, the 429 response body names the policy. HubSpot's own Node client waits ten seconds and retries when the policy is TEN_SECONDLY_ROLLING, waits a second when a search endpoint reports its separate per-second limit, and retries nothing else (HubSpot API client). Copy that behavior: a daily limit won't reset in ten seconds, and retrying against it only burns requests. For bulk changes, use the batch endpoints, which carry many records per request.

6. The sandbox is an old copy of production

Salesforce sandboxes come in four types: Developer, Developer Pro, Partial Copy and Full. Creating or refreshing any of them copies production's metadata, and optionally data, at that moment (Salesforce CLI). Nothing flows across afterwards. A validation rule or flow added to production next month doesn't exist in the sandbox, so the integration first meets it in production.

Before integration testing starts, and again before go-live, have the admin compare the two orgs. These commands list every validation rule and flow in each, with its last-modified date:

sf org list metadata --metadata-type ValidationRule --target-org prod --output-file prod-rules.json
sf org list metadata --metadata-type ValidationRule --target-org uat --output-file uat-rules.json
sf org list metadata --metadata-type Flow --target-org prod --output-file prod-flows.json
sf org list metadata --metadata-type Flow --target-org uat --output-file uat-flows.json

Any name that exists only in production, or that was modified in production after the sandbox's refresh date, is untested behavior. The user running these needs the Modify All Data or Modify Metadata Through Metadata API Functions permission.

Two smaller differences break integrations on their first production run. Sandbox usernames get the sandbox name appended, so integration@example.com becomes integration@example.com.uat, and credentials must be configured per environment. And records created during testing don't exist in production, so any record ID typed into the integration's configuration will fail there. Configure by external ID or by name instead.

HubSpot's sandboxes, where your subscription includes them, are also copies made at a point in time (HubSpot Knowledge Base). Before testing, check which workflows and property settings changed in production since yours was created.

7. The integration user sees too much, or too little

Integrations get broad access because it's the quickest way to make them work. Then delivery staff see negotiated discounts, because the whole deal was copied rather than the six fields delivery needs. The fix starts with how the integration logs in.

Salesforce: the Integration user license

Enterprise, Unlimited and Performance editions include five Salesforce Integration user licenses at no extra cost, as of September 2026. A user on this license is API-only: it can call the REST, SOAP and Bulk APIs but can't log in to the interface. It comes with the Minimum Access – API Only Integrations profile, and the admin grants object and field access through permission sets on top of the Salesforce API Integration permission set license (Salesforce Help). Salesforce's admin team recommends one such user per integration (Salesforce Admins blog), which is also what makes Last Modified By meaningful in challenge 3.

Too little access fails quietly. API queries return only the records the running user can see, with no error for the rest, so an integration user missing one region's sharing rule produces a sync that looks complete. When records from one territory go missing, check the integration user's access first.

HubSpot: scopes, and the end of new legacy private apps

HubSpot access is granted by scopes, per object and per action, such as crm.objects.deals.read. Properties marked as sensitive data need separate sensitive scopes (HubSpot developer docs). There is no scope for "these six properties", so a token that can read deals can read every non-sensitive deal property. If only a few fields should leave HubSpot, the integration's code has to be the filter, with an explicit field list.

The credential itself is changing. HubSpot is disabling creation of new legacy private apps, the account-level tokens that internal integrations have used until now: from September 28, 2026 for accounts created on or after that date, and from October 26, 2026 for existing accounts. Existing legacy private apps are not affected at this time. The replacement for system-to-system integrations is Service Keys, in public beta, created under Settings, Integrations, Service Keys (HubSpot changelog). HubSpot recommends rotating these tokens every six months. If an integration is planned for next quarter, plan it on Service Keys.

If you don't know which integrations already hold CRM credentials, find out before adding another. Our legacy system examples cover where forgotten integrations and their credentials usually hide.

8. A batch half-succeeds and reports success

Batch endpoints report results per record, which means a request can succeed while some of its records fail. A HubSpot batch upsert can answer 207 Multi-Status, meaning some inputs worked and some didn't (HubSpot developer docs). A Salesforce bulk job can finish with failed records, which sf data bulk results --job-id retrieves. An integration that checks only the HTTP status logs success for both.

Make every batch prove its arithmetic: records submitted must equal records succeeded plus records failed, and failed must be zero or accounted for. In HubSpot, set each input's objectWriteTraceId to your source record ID, so each error in a 207 response names the record it belongs to. After a bulk change, check the result from the CRM's side too. For the reassignment in challenge 5, a query such as SELECT COUNT() FROM Account WHERE OwnerId = '005...' for each new owner should match the row counts in your CSV.

Each failed record then needs a place a person will see it. What that exception queue should contain, and who works it, is covered in our guide to connecting business systems.

The pre-build CRM integration worksheet

Fill this in with your CRM admin before anyone quotes or builds. The defaults are ours. "We're not sure" is a useful answer, because it shows you where the risk is.

Pre-build CRM integration worksheet
DecisionOur defaultAsk your CRM admin
Customer keyThe other system's customer number in a unique External ID field (Salesforce) or unique-value property (HubSpot). All writes are upserts on it. Files use 18-character IDs from a CASESAFEID(Id) formula field.Which field holds the key today, and is it unique? In HubSpot, how many of the ten unique properties are used?
Required fieldsEnforced by validation rule at the stage where the handoff fires.Which active validation rules fire on the objects we write, and which should apply to the integration?
Direction per fieldOne way, from the owner in the field ownership table.For each synced field, which system do people correct first?
Loop preventionFingerprints of synced fields, a dedicated integration user, and reads by SystemModstamp with overlapping windows.Which flows, workflows or scheduled jobs update fields we plan to sync?
MergesNightly repointing from MasterRecordId (Salesforce). Update stored IDs whenever HubSpot returns a different one.Who merges records, and how often? Is our HubSpot account in the Primary ID Preservation beta?
DeletesNever delete in a finance system. Flag for a person.When an account is deleted, what should happen to its invoices and open orders?
Bulk changesBulk API 2.0 or batch endpoints, measured API headroom, and submitted, succeeded and failed counts checked on every batch.Which mass updates do you run, and when? What does sf org list limits show for daily API requests?
SandboxRefreshed before testing, with a clean metadata comparison against production.When was it last refreshed, and which rules or flows changed in production since?
Integration identitySalesforce Integration user with permission sets, or HubSpot Service Key with named scopes. An explicit list of fields the integration may read.Is an Integration user license free? Which objects and fields may this integration read and write?

A native connector or an integration platform may handle all of this. The worksheet then becomes the list you test it against, and the specification if you conclude something has to be built.

If a CRM connection in your business is producing duplicates, echo loops or totals nobody trusts, bring the completed worksheet and one record that went wrong. We'd start with the key it was matched on and the user that last modified it. Our systems integration work begins there.

Build with Adamant Code

Is your business outgrowing its tools?

Bring one workflow or software problem. We’ll discuss where your current setup falls short and the next step worth exploring.

Talk through your workflow
CRM Integration Challenges: Exact Fixes in Salesforce and HubSpot | Adamant Code