Salesforce integration follows the same architectural pattern as HubSpot — OAuth, token management, paginated fetching, batch upsert — but against a different API surface. This chapter focuses on the differences rather than repeating the shared patterns from Chapter 11.
The Same Pattern, Different API
Both CRM integrations share:
- OAuth 2.0 authorization flow
- Token storage in
crm_connections(provider = ‘salesforce’) - Automatic token refresh before expiry
- Batch company/contact/deal sync using UNNEST
- Entity resolution by provider ID and email domain
The key differences:
| Aspect | HubSpot | Salesforce |
|---|---|---|
| API Style | REST with pagination cursors | REST + SOQL query language |
| Auth URL | app.hubspot.com/oauth/authorize | login.salesforce.com/services/oauth2/authorize |
| Token URL | api.hubapi.com/oauth/v1/token | login.salesforce.com/services/oauth2/token |
| Entity Names | Companies, Contacts, Deals | Accounts, Contacts, Opportunities |
| Property Access | Property name string | Field API name |
| Webhook Events | Subscription-based (deal.propertyChange) | Change Data Capture (OpportunityChangeEvent) |
| Employee Field | numberofemployees (integer) | NumberOfEmployees (integer) |
| Revenue Field | annualrevenue (number) | AnnualRevenue (currency) |
SOQL: Salesforce’s Query Language
HubSpot uses paginated REST endpoints. Salesforce uses SOQL (Salesforce Object Query Language) — a SQL-like language for querying CRM data:
SELECT Id, Name, Website, Industry, NumberOfEmployees, AnnualRevenue,
BillingCountry, BillingState, BillingCity, Phone, Description
FROM Account
WHERE LastModifiedDate > 2024-01-01T00:00:00Z
ORDER BY LastModifiedDate DESC
LIMIT 200This is more flexible than HubSpot’s property-based fetching — you can filter, sort, and join in the query itself. But it also means the sync service needs to construct valid SOQL strings, which introduces SQL-injection-like risks. All user input is escaped before being placed in SOQL queries.
Webhook Format: Change Data Capture
Salesforce webhooks use Change Data Capture (CDC) events, which are structurally different from HubSpot’s subscription events:
// HubSpot event:
{
subscriptionType: 'deal.propertyChange',
objectId: 12345,
propertyName: 'dealstage',
propertyValue: 'closedwon'
}
// Salesforce CDC event:
{
ChangeEventHeader: {
entityName: 'Opportunity',
changeType: 'UPDATE',
changedFields: ['StageName', 'Amount'],
recordIds: ['006xx000001abc'],
commitTimestamp: 1711234567000
}
}The alert evaluation service normalizes both into a common format (Chapter 15), so the trigger matching logic doesn’t need to know which CRM the event came from.
Entity Name Mapping
Salesforce uses different terminology than HubSpot and Astrelo:
Salesforce → Astrelo → Database Table
────────────────────────────────────────────────
Account → Company → companies
Contact → Contact → contacts
Opportunity → Deal → deals
OpportunityContactRole → Deal Contact → deal_contacts
Task → Task → tasksThe sync service handles this mapping transparently. From the scoring engine’s perspective, a company is a company whether it came from HubSpot or Salesforce.
Multi-CRM Support
A user can only have one active CRM connection at a time. The crm_connections table enforces this with a UNIQUE(user_id, provider) constraint. If a user switches from HubSpot to Salesforce, the old connection is deactivated (is_active = false) and a new one is created.
The scoring engine doesn’t care which CRM is connected — it reads from companies, contacts, and deals regardless of source. The external_source column on each record tracks the origin (‘hubspot’ or ‘salesforce’) for sync conflict resolution.
Key Takeaways
- Same architecture, different API. Both CRMs follow the OAuth → Sync → Score pipeline.
- SOQL vs REST pagination is the biggest technical difference.
- CDC events provide richer change information than HubSpot’s property-change subscriptions.
- Entity mapping is handled at the sync layer — the rest of the app is CRM-agnostic.
Next chapter: Slack integration — how alerts get delivered outside the app.