The remit
One operating model, built inside three regions at once.
Stefanini runs marketing and sales operations across Latin America, the United States and EMEA. My remit is global: the CRM strategy for all three, and the hands-on build that follows it. Not a model designed in one region and exported to the others.
That distinction is the whole job. A rule that works in Brazil can break in Germany because the list came from a different source, the language is different, and the sales team measures a pipeline its own way. Every decision in this case had to survive three markets, three teams and three sets of habits before it counted as done.
The diagnosis
Before changing anything, I mapped what was actually being used.
The discovery had three fronts running together: a technical inventory of automations, properties and the sync errors between Salesforce and HubSpot; conversations with each area about what the CRM did to their day; and a hard look at what the base was costing and whether the emails were even arriving.
- Overlapping bases Every team could see every contact, so the same person was approached by different areas about different subjects, sometimes in the same week.
- Crossed communication With no segmentation by language, Portuguese-speaking contacts were receiving emails written in Spanish.
- Cost climbing on its own The inflated base kept pushing the marketing contact count into more expensive HubSpot tiers, paying to store people nobody was talking to.
- Deliverability at risk Bounce rates were high enough to put every campaign at risk, and high bounce is what eventually costs a sender its reputation. At this point that was the fear, not yet a diagnosis.
The clean-up
65.437 contacts removed in five days.
Between 29 April and 3 May 2026 the inactive base came out. The marketing base went from 180 thousand contacts to around 115 thousand, and what stayed was the part that had actually engaged.
Two things happened at once. The contact count fell back below the contracted HubSpot tier, which stopped the bill from climbing, and the volume being sent to disengaged people dropped, which takes pressure off every send that follows.
What this did not solve was the bounce itself. Inactive and invalid are different problems: this removed people who had stopped engaging, not addresses that never existed. That second problem needed its own investigation, and it is the next section.
The investigation
Everyone said the domain was burned. The data said otherwise.
LATAM campaigns kept failing. HubSpot stops a send automatically once bounce passes 5%, and it was happening again and again. The assumption in the room was sender reputation, which would have meant months of warm-up and a new sending domain.
I pulled 50 campaigns through the HubSpot Marketing Email API, covering August 2025 to May 2026, and cross-checked the sending domain against blacklists. 50.993 emails, 5.680 bounces. The pattern was not global. It was regional.
The comparison below is drawn on the campaigns that crossed the 5% limit in each region, 31 in BR and LATAM against 7 in EMEA and North America. That is the like-for-like cut: campaigns already in trouble, measured against each other.
- The same email, a "clean list", the same result. One campaign was resent to a list the team had labelled clean. It bounced at 32,1% against 37,1% on the original. The contamination was systemic, not a bad file.
- The domain was clean on 70 blacklists. MXToolbox returned zero listings for the sending domain, including Spamhaus, Barracuda and SpamCop. A reputation problem leaves traces there. There were none.
- The bigger region bounced less. EMEA and North America sent three times the volume at half the bounce. A server or domain problem would hit every region the same way.
- It was recurrent, not an incident. Of the 50 campaigns over the limit, 31 were BR and LATAM. The pattern held at volume: one LATAM send bounced 957 times out of 3.405. The worst rate, 75%, came from a small list of 16, which makes it an illustration rather than evidence.
- Immediate Every contact from campaigns above 20% bounce validated before the next send.
- Short term A permanent exclusion list in HubSpot, filtered on known hard bounce, applied to every LATAM send.
- Structural A quality gate on Salesforce imports: no list enters HubSpot without validation first.
- Ongoing Monthly bounce reporting by region, pulled automatically through the API, so degradation is caught before HubSpot stops a campaign.
The architecture
Two platforms, and an end to the argument about which one is the truth.
The redesign settled ownership. HubSpot belongs to marketing and the SDR team, where leads are generated, nurtured, sequenced and qualified. Salesforce belongs to the commercial team, where accounts, contacts and opportunities live once they are real. Neither platform tries to be both.
The work ran in two speeds on purpose: minimum adjustments to keep every team operating without rupture, while the new architecture was designed in parallel. Nobody had to stop selling while the model was rebuilt underneath them.
- One entry point, not four Outbound, forms and campaigns consolidated into a single flow, so a lead cannot exist twice with two different owners.
- Sequences owned by the SDR team Cold outbound runs in Instantly and nurture runs in HubSpot, but cadence, channel and stop conditions are defined in one place, and replies and bounces feed back into the same record.
- Language as a segmentation rule After the crossed communication found in the diagnosis, language became a condition for entering any sequence.
- A meeting is the trigger Nothing reaches Salesforce because someone felt it was ready. A lead in Scheduled Meeting is what starts the handoff, and section 11 shows exactly what happens next.
The handoff is the part that usually fails, so it was written as code rather than left to judgement. A diagram like the one above is easy to draw. Making two platforms agree on it every fifteen seconds, against a production org that other teams and integrations are using at the same time, is the rest of this case.
Three orgs, one key
Before syncing anything, I had to find out which IDs were real.
Stefanini moved from an old Salesforce org to a new production org in August 2026, and HubSpot had been loaded from the old one. So there were not two systems to connect, there were three, and the properties that looked like links between them pointed at the wrong place.
HubSpot's salesforce_contact_id was filled on 370.581 contacts. In a sample of 300,
297 were IDs of Lead records in the old org, and zero existed in production. Any sync keyed
on that property would have matched nothing, or worse, would have looked like it worked. The animation
below shows the problem and the bridge I built.
salesforce_contact_id values found in productionThe same logic applied to companies, with a bigger consequence. I rebuilt the company base in HubSpot as
a strict mirror of Salesforce: one company per account, with the account ID in
id_account_salesforce_new_org and the name identical to the account's, in the
Name_COUNTRY format the org already uses. When the ID and the name disagreed, the ID won,
840 times. The 15.065 internal _Global accounts stayed out by decision. Companies with no
contact, no lead and no account went to the recycle bin, still recoverable. Salesforce was not written
once: it is the source, HubSpot is the mirror.
The country suffix came from a ranked cascade of signals, because no single field was
reliable: the account's country, then the company's country, then the suffix of the name, the tax ID
format, the domain's TLD, and finally the country of the associated contacts. Where nothing said anything,
the company got _WITHOUTCOUNTRY (778 cases) instead of a guess.
Reading both orgs side by side surfaced one more thing nobody had asked about. The migration had collapsed
around 34 granular lead origins into 8 macro values. Of the 8.452 opportunities of 2026 that I could trace
back to the old org, 98,6% now show a different origin than the one recorded at the time. The bridge
exists, Old_Org_ID__c is filled on 166.242 of 167.748 opportunities, so attribution for 2026
was rebuilt by reading the old org, not by trusting the field in the new one.
One cycle of the middleware
Every fifteen seconds, one small Python process asks both platforms what changed.
The middleware runs as a service on a Linux server, against the production org, since 3 September 2026. Before it was allowed to write, it ran 15 cycles in simulation mode, and none of them proposed a write it should not have made. Then it went live.
Press play to watch one cycle with work in it: a campaign created in HubSpot, and a contact enrolled in it. Each line is a real step, in the real order. A quiet cycle, with nothing to do, takes about three seconds and costs three calls to HubSpot and four to Salesforce.
The org's daily API allowance is shared by every integration Stefanini runs. On the day I measured it,
roughly half of the 490.000 daily calls were already used by others, and the org blocks the limits endpoint
for the integration user, so the remaining budget is read from the Sforce-Limit-Info header on
every response instead. That is why two SOQL queries travel in one /composite request, which
Salesforce counts as a single call, and why the middleware never asks HubSpot for a record it already holds
in its local index.
- New records come from the listing, not from search HubSpot's search index lags a few seconds behind creation. A record created inside that gap would be missed forever by a cursor based on search. New records are read from the object listing; search is only used for changes, with a 15-minute overlap.
- Simulation is the default
Every script prints what it would write and writes nothing unless it is called with
--aplicar. Test records carry a prefix, so a run can be restricted to them and cleaned up afterwards on both sides. - The org is locked in code The HTTP layer refuses any Salesforce host that is not the expected production domain, and any sandbox, before the first write. A swapped environment file cannot reach the wrong org.
- Only from now on On its first run the middleware recorded what already existed without a key as historic, and left it alone. Releasing history is an explicit, per-record decision, never a side effect of switching it on.
The field map
One file decides how every field crosses, and it is the same file the code reads.
The mapping lives in a single Python module. The sync reads it, and the spreadsheet the business team reviews is generated from it, from live metadata on both sides. When the two disagree there is nothing to reconcile, because there is only one.
Every API name below was checked against Salesforce's describe() and HubSpot's properties
endpoint before the first write. Use the tabs or type a field name to filter. The arrow shows which way the
value travels.
| HubSpot property | Direction | Salesforce field | What the code does with it |
|---|
- Map what is unambiguous, report the rest 800 of 1.849 HubSpot campaigns carried legacy types with no equivalent in the production picklist. The translation table holds only exact equivalences. Anything else is left out of the payload and reported, instead of being forced into a value that happens to be accepted.
- Match on the label, not the internal value Industry and Sub-Industry already existed in HubSpot with the same labels as Salesforce but different internal values. The sync resolves options by label, so nobody had to rebuild two properties.
- Dependent picklists, decoded
SubIndustry__cdepends onIndustry__c. Valid combinations come from thevalidForbitmap in the describe, decoded in code, so an invalid pair never reaches the API. - Options are only ever added A HubSpot PATCH on options replaces the whole list. The script that completes picklists reads the current options first and only appends, so a missing value can be fixed without deleting anyone else's.
Who wins a conflict
Two-way sync without ping-pong comes down to one decision per field.
The classic failure of a bidirectional sync is a loop: A writes to B, B's change is seen as new, B writes back to A, forever. The way out is not clever timing. It is deciding, for each kind of data, who is allowed to create it and who is allowed to change it, and writing that down where the code can enforce it.
| Data | Created by | Updated by | Rule |
|---|---|---|---|
| Campaigns and events | HubSpot | HubSpot | Marketing owns the name, dates, description and type. Status and IsActive are not mapped, so decisions made on the Salesforce side survive. |
| Enrollment (members) | Both sides | Salesforce | Creation is bidirectional, status is always Salesforce's. The HubSpot side never overwrites a status, and that single rule is what removes the loop. |
| Contacts | Salesforce | Nobody | Creation only. If the email already exists in HubSpot, the records are linked and only empty fields are filled. A contact already linked to another Salesforce ID is reported, not touched. |
| Companies | Salesforce | Nobody | Business accounts only. Found by account ID, then by exact name with no ID, then linked. Never a second company for the same account. |
| Keys and links | The middleware | Nobody | The Salesforce ID and the Lightning link are written back once, at creation. Properties holding old-org IDs are on a forbidden list and any write-back that touches them is refused. |
What the docs do not say
Most of what I know about this org, I learned by measuring it.
Metadata tells you what a platform allows. It does not tell you what an automation does to a record one millisecond after you save it, or what a search index sees. Every row below was measured against a live org, and every one changed the code.
| What the platform suggests | What actually happened | What the code does now |
|---|---|---|
| A 201 means the contact was saved as sent | An automation overwrote the mailing country with the account's Country__c and silently dropped the state. Success reported, wrong address stored. | Reads back after every create. Never trusts the status code alone. |
Only LastName is required on Contact | The page layout and validation rules require 13 fields, including role, scope, department and sub-department, salutation and currency. | Reads required fields from the layout's create defaults, not from the describe. |
| The owner you send is the owner you get | An automation copies the account owner onto the contact and ignores the one sent. An inactive account owner makes the insert fail. | Sends no owner. Checks the account owner is active before trying. |
deletable: true on Pre-Account | The record is locked by automation the instant it is created. Delete returns ENTITY_IS_LOCKED. | Creation is treated as final: simulate, then create once. |
| A campaign created by API is a campaign people can see | It is born inactive with no status, and every list view in the Campaigns tab filters on active. It exists, and nobody finds it. | Events are created active and In Progress, on creation only, so a later manual inactivation survives. |
| A duplicate rule blocks a save | It returns a 400 with allowSave: true in the body. It is a warning dressed as an error. | Separates hard failures from overridable duplicate warnings. |
| A merge keeps the record ID | A HubSpot merge creates a new ID and redirects the old ones. In a chain of merges, the second must target the ID returned by the first. | Follows the returned ID, and checks domains right after merging, before associating anything by domain. |
| A workflow that is on is working | A native workflow that created HubSpot campaigns stayed switched on and stopped acting, with no error visible anywhere. | Replaced by a middleware step with its own queue and log: 81 pending records served, zero duplicates. |
| Text fields are text fields | Description holds 32 thousand characters on Campaign and 255 on the custom Event object. | Truncates on the Salesforce side only, keeps the full text in HubSpot, and reports it. |
From meeting to account
The handoff: a booked meeting in HubSpot becomes an account and a contact in Salesforce.
This is the flow the architecture was built for. When an SDR moves a lead to Scheduled Meeting, the commercial team needs the person and the company in Salesforce, attached to the right account, without anyone retyping them.
The hard part is that the integration user cannot create an Account in this org, by design. New companies go through a Pre-Account, a request that a person at Stefanini reviews and converts. I measured five real conversions: 8, 38, 42, 61 and 73 minutes. A flow designed to wait seconds would be wrong, so the middleware keeps a persistent queue and checks it once per cycle, with a single query for the whole queue.
- ProvenEnd to end in the sandbox Run with the real poller, no mocks: a Pre-Account submitted, converted by a person in about 40 minutes, the HubSpot company renamed to the account's name, and the contact created under the new account. A third cycle wrote nothing, which is the proof of idempotency.
- ProvenPre-Account in production 153 of the 158 Pre-Accounts in production were already converted, with the new account ID filled in. The contract the middleware relies on is the one the team uses every day.
- NextContact creation in production Creating contacts was blocked for the integration user in production. I asked for it with the exact list of fields and why, and it was opened on 29 September. Go-live is the next step.
- NextTwo lead pipelines A second lead pipeline was created with the same stages. The stage is resolved by label today, so the trigger will accept a list of stages before it goes live.
Server communication
Polling got it live. Events are where it goes next.
Polling was the right first choice: no inbound endpoint to secure, nothing to configure on either platform, and it could be switched on in a day. It also has a cost that grows with time. Most cycles ask both platforms a question whose answer is "nothing new".
On 5 October connection resets between the server and Salesforce's edge jumped from about 5 a day to 169 in six hours. The platform status was clean and the cause was outside my reach. What it exposed was inside it: retries covered server errors and rate limits, not dropped connections, so a cycle that died halfway was redone by the next one, and 19 enrollment records were duplicated on the HubSpot side before a cleanup pass removed them. That incident wrote the target design below.
| Piece | How | Why |
|---|---|---|
| HubSpot to server | Webhook subscriptions on creation and on the mapped properties of campaigns, events, contacts and leads. Each request carries X-HubSpot-Signature-v3, an HMAC SHA-256 of method, URL, body and timestamp. | The endpoint rejects anything unsigned or older than five minutes, so a replayed or forged call does nothing. |
| Salesforce to server | Change Data Capture on Contact, Account, CampaignMember and the Pre-Account object, read through the Pub/Sub API, with the replay ID stored after each batch. | The Pre-Account conversion arrives as an event the moment a person converts it, instead of being asked about every cycle. |
| Acknowledge, then work | The endpoint validates, writes to a queue and answers in under a second. Workers do the slow part. | HubSpot retries webhooks that time out. A fast ack keeps retries for real failures. |
| Idempotency | Every message has a key: object, record ID and version (modified date or replay ID). A key already processed is skipped. | A retry, a duplicate delivery or a crash halfway can never create a second record. This is the fix for the October duplicates. |
| Retries | Exponential backoff with jitter on rate limits, server errors and dropped connections. After the last attempt, the message goes to a dead-letter queue and a phone alert. | Nothing fails silently, and nothing is retried forever. |
| Writes in bulk | sObject Collections, up to 200 records per call, on the Salesforce side. HubSpot batch endpoints, up to 100 per call. | A burst after an event import costs a handful of calls, not hundreds, against the shared daily allowance. |
| Reconciliation | The current poller stays, every six hours, comparing both sides in full. | Webhooks and events can be lost. The safety net already exists and already works. |
What I take from it
Global CRM work fails in one of two ways. Either one region's model is imposed on everyone and nobody adopts it, or every region keeps its own and there is nothing to report on. Getting between those two is the actual craft, and it is done in conversations with local teams rather than in a diagram.
The second lesson is the same one twice. Three months of domain warm-up were on the table for a problem sitting in the destination addresses. And a sync keyed on an ID property that looked right would have matched nothing in production. Both times the fix started with a measurement, not with a build.
The third is about integrations specifically. The code that moves data is the easy part. The value is in the ownership rules, in knowing what a platform does after it says 201, and in a safety net that assumes every one of those rules will be tested by real data, on a real org, while other people are using it.