CRM Integration Implementation Recipes: Salesforce & HubSpot Architecture (2026)
Architectural blueprints and production-ready recipes for HubSpot & Salesforce integrations: token bucket rate limiting, Bulk API 2.0, and dynamic custom field discovery.
Building a CRM integration to Salesforce or HubSpot looks trivial in a sandbox environment. You authenticate once, make a single HTTP request to a contacts endpoint, parse a JSON response, and render it in your UI. Then you push it to production with a hundred enterprise customer accounts behind it and watch HTTP 429s, REQUEST_LIMIT_EXCEEDED errors, broken pagination cursors, and __c custom field mismatches turn your sprint into a six-month maintenance project.
The market opportunity is massive. Fortune Business Insights reports the B2B SaaS market is expanding rapidly, projected to reach $634.39 billion in 2026 at a 27.54% CAGR. However, the European B2B CX Benchmark Report 2025-2026 indicates companies typically manage customer data across an average of 12 different systems. Salesforce and HubSpot sit at the center of that fragmented ecosystem, which means your integration is not just talking to a CRM - it is competing for API budget with every other middleware tool already installed in your customer's org.
This guide provides actionable architectural blueprints for building native-feeling integrations with Salesforce and HubSpot, specifically addressing the technical hurdles that cause integrations to fail at scale. If you want the wider context on why these projects drain engineering time, read Building Native CRM Integrations Without Draining Engineering in 2026. The rest of this post is your technical recipe book.
The "Just a Few API Calls" Trap: Why CRM Integrations Fail in Production
B2B SaaS CRM integration failure occurs when engineering teams scope third-party API connections based on vendor "Getting Started" guides, ignoring the production realities of strict rate limits, undocumented pagination behaviors, and highly customized data schemas.
A CRM integration is not a simple HTTP client. It is a distributed system, especially when architecting real-time CRM syncs for enterprise. When your product manager asks for a Salesforce integration, the initial engineering estimate usually covers the basic HTTP request path. That represents roughly 10% of the actual work. The remaining 90% involves managing OAuth token lifecycles, handling burst rate limits across tenants, building exponential backoff queues, and dealing with custom objects that break your static data models.
Three failure modes show up in production for almost every team:
- Burst exhaustion: Your sync runs fine at 10 customers and falls over at 100 because you treated rate limits as a single number instead of a token bucket.
- Custom schema drift: Half your enterprise accounts have custom objects or properties you have never seen, and your hardcoded field mappings start returning
nullfor the data customers actually care about. - Silent pagination loss: Cursor-based pagination breaks when a record is created or deleted mid-walk, and your incremental sync starts missing records without throwing a single error.
The rest of this article gives you a recipe for each of these, then explains the architectural shift that removes most of the work.
Recipe 1: Navigating HubSpot API Rate Limits and Pagination
As covered in our 2026 architecture guide to building a HubSpot integration, HubSpot's API is generally developer-friendly, utilizing standard REST conventions and JSON payloads. However, its rate limiting strategy is aggressive. HubSpot's API is the textbook example of a token bucket model. Misread it as a simple "X requests per second" cap and you will burn your daily quota fighting 429s.
The Actual HubSpot Rate Limit Numbers (2026)
For public OAuth apps, the burst limit is 110 requests per 10 seconds per installed account. For private apps on Pro and Enterprise tiers, the burst limit is 190 requests per 10 seconds.
Daily quotas matter more than people think. Free/Starter plans allow 250,000 daily requests. Enterprise plans offer up to 1,000,000 daily, with add-ons available for more capacity.
The Search API is the silent killer. The CRM search API (/crm/v3/objects/contacts/search, etc.) is capped at 5 requests per second across all search endpoints. That is not per endpoint - it is shared across contacts, deals, companies, and every other object type. If you use search for deduplication checks on inbound writes, it - not the burst limit - becomes your bottleneck.
Implementing a Token Bucket Rate Limiter
To handle this, your architecture must decouple integration logic from direct HTTP execution. You need an outbound request queue governed by a token bucket algorithm.
graph TD
A[Application Logic] -->|Enqueue Request| B(Redis Queue)
B --> C{Token Bucket Check}
C -->|Tokens Available| D[Execute HTTP Request]
C -->|Tokens Depleted| E[Delay & Re-queue]
D --> F{Response Status}
F -->|200 OK| G[Process Data]
F -->|429 Too Many Requests| H[Exponential Backoff]
H --> BA naive sleep(100) between requests leaves throughput on the table. The token bucket model means you can spike above the average rate temporarily. A well-designed client takes advantage of this: send requests as fast as you need to, but build in breathing room so the bucket recovers.
Here is a practical TypeScript implementation of a token bucket sized for HubSpot's public app tier:
// Token bucket for HubSpot - 110 burst, ~10 tokens/sec refill (public app)
class HubSpotRateLimiter {
private tokens = 110
private lastRefill = Date.now()
private readonly refillPerMs = 10 / 1000
private readonly capacity = 110
async acquire(): Promise<void> {
this.refill()
if (this.tokens >= 1) {
this.tokens -= 1
return
}
const waitMs = Math.ceil((1 - this.tokens) / this.refillPerMs)
await new Promise(r => setTimeout(r, waitMs))
return this.acquire()
}
private refill() {
const now = Date.now()
this.tokens = Math.min(
this.capacity,
this.tokens + (now - this.lastRefill) * this.refillPerMs
)
this.lastRefill = now
}
}Pair this with exponential backoff and jitter on 429s. If you receive an HTTP 429 response, you must respect the Retry-After header. Pair that with a small randomized delay (0-9 seconds of jitter) before each outbound call so 10,000 enrollments don't align perfectly. If you continue to hammer the API without jitter, HubSpot will temporarily ban your OAuth application from making further requests for that specific tenant.
Handling Cursor-Based Pagination and Batching
If you only learn one thing about HubSpot efficiency: batch endpoints count as one request. HubSpot's batch create/update endpoints let you process up to 100 records in a single API call. From a rate limit perspective, that is 100x more efficient than individual calls.
API pagination consistency is another major hurdle. HubSpot uses cursor-based pagination. When you request a list of contacts, the response includes a paging.next.after token.
{
"results": [
{ "id": "123", "properties": { "firstname": "Alice" } }
],
"paging": {
"next": {
"after": "NTI1Cg%3D%3D",
"link": "?after=NTI1Cg%3D%3D"
}
}
}Your integration must recursively or iteratively fetch pages using this after parameter until the paging.next object is null. Do not rely on offset-based pagination for HubSpot, as it is inefficient and HubSpot reorders results on deletes, meaning you will drop records. For more on bidirectional sync challenges, review our bidirectional HubSpot sync tutorial.
Recipe 2: Conquering Salesforce Governor Limits and SOQL Queries
As we noted in our hands-on guide to building a Salesforce API integration, Salesforce is an entirely different beast compared to HubSpot. It is essentially a massive relational database exposed over the web. Salesforce integrations frequently fail in production not because of load, but because they violate strict governor limits.
The Limits That Actually Bite
The base daily quota varies by edition: Enterprise Edition starts at 100,000 calls per day plus 1,000 per user license. That sounds like a lot until you remember every middleware tool in the customer's org draws from the same pool.
The Daily API Request Limit is a soft limit, and your org is allowed to exceed it temporarily. The 403 error with REQUEST_LIMIT_EXCEEDED is what you actually need to handle when the system protection limit kicks in.
Concurrency is the real trap. Salesforce limits the number of concurrent long-running (20+ second) requests to 25 on production orgs. If the limit is exceeded, any new concurrent requests will not be processed. Long-running SOQL queries during a backfill will block your customer's other integrations.
Building Safe SOQL Queries and Keyset Pagination
To fetch data efficiently from Salesforce, you must use the Salesforce Object Query Language (SOQL). REST API endpoints exist for individual records, but bulk extraction requires dynamic SOQL construction.
SELECT Id, FirstName, LastName, Email, AccountId
FROM Contact
WHERE LastModifiedDate > 2026-01-01T00:00:00Z
ORDER BY Id ASC
LIMIT 2000Notice the ORDER BY Id ASC. This is critical. Salesforce's standard offset pagination (OFFSET 2000) maxes out at 2,000 records. If you need to sync 50,000 contacts, standard offset pagination will hard-fail. You must implement "keyset pagination" - tracking the highest Id from the previous batch and using it in the WHERE clause of the next query (WHERE Id > 'Last_Seen_Id').
Never construct SOQL by string concatenation against user input. For dynamic filtering across customers with different field sets, build the query off the org's actual describe metadata, not your assumption of what fields exist:
async function buildContactQuery(sf: Connection, filters: Filter[]) {
const describe = await sf.sobject('Contact').describe()
const validFields = new Set(describe.fields.map(f => f.name))
const where = filters
.filter(f => validFields.has(f.field))
.map(f => `${f.field} ${f.op} ${escape(f.value)}`)
.join(' AND ')
return `SELECT Id, FirstName, LastName, Email FROM Contact WHERE ${where} LIMIT 200`
}Utilizing Bulk API 2.0 for Backfills
For any operation involving more than a few thousand records, you must abandon the standard REST API and use Salesforce Bulk API 2.0. This is an asynchronous API designed for large data volumes that allows enormous throughput: up to 150 million records per rolling 24 hours. Each batch can contain up to 10,000 records, and each 10k batch counts as only 1 call against the daily limit.
- Create a Job: Send a POST request to define the object (e.g., Contact) and operation (e.g., query, insert, upsert).
- Upload Data: If inserting, upload CSV data to the job endpoint.
- Close the Job: Signal to Salesforce that the upload is complete.
- Poll for Status: Continuously poll the job status endpoint until it reads
JobComplete. Do not poll aggressively - check status every 30-60 seconds. - Retrieve Results: Fetch the successful records and the error logs.
This architecture requires your application to maintain durable state. You cannot hold an HTTP connection open waiting for a Bulk API job to finish. You must persist the Job ID to your database and use a background worker to poll for completion.
Recipe 3: Handling Salesforce Custom Objects and HubSpot Custom Properties
This is where most unified data models break. No two enterprise Salesforce instances are identical. If your integration assumes that a Contact object only has FirstName, LastName, and Email, it will break immediately upon deployment to a mid-market or enterprise customer.
The Salesforce __c Suffix Pattern
In Salesforce, when a customer creates a custom field for "Lead Source Detail", the API exposes it as Lead_Source_Detail__c. Every custom field and custom object in Salesforce ends in __c.
Your integration must first query the Salesforce metadata API to discover available fields for the authenticated tenant:
GET /services/data/v59.0/sobjects/Contact/describeThis endpoint returns a massive JSON payload detailing every field. You must filter the field list where custom === true (or match /__c$/), cache this schema on a TTL (24 hours is fine), and emit those fields as an open-ended map. Read our deep dive on how to handle custom fields and custom objects in Salesforce via API for specific UI mapping patterns.
HubSpot Dynamic Properties
HubSpot handles custom data differently. Instead of appending suffixes, HubSpot nests custom data within a properties object.
{
"properties": {
"firstname": "John",
"custom_industry_vertical": "SaaS",
"internal_scoring_metric": "85"
}
}Crucially, HubSpot will not return custom properties unless you ask for them by name in the GET request. That single gotcha causes more "missing data" support tickets than anything else. You must discover them via /crm/v3/properties/contacts and explicitly request them:
const defaultProps = new Set([
'firstname', 'lastname', 'email', 'phone',
'jobtitle', 'address', 'city', 'state', 'zip', 'country'
])
const customProps = allProperties
.filter(p => !defaultProps.has(p.name) && !p.hubspotDefined)
.map(p => p.name)
// Include them explicitly in the GET call
const url = `/crm/v3/objects/contacts?properties=${[...defaultProps, ...customProps].join(',')}`How Truto Normalizes CRM Complexities Without Custom Code
Everything above is correct, and every team that builds it from scratch ends up writing roughly the same code. Building out token buckets, keyset pagination, SOQL query builders, and dynamic schema discovery for just two CRMs will consume months of engineering time. Adding Microsoft Dynamics, Zoho, and Pipedrive will consume the rest of your year.
After enough integrations, you start to see the pattern: the differences between HubSpot and Salesforce are mostly data, not logic. The HTTP client, the pagination loop, the OAuth refresh, the response shape - those should be one piece of code parameterized by configuration.
Truto's architecture eliminates this burden entirely. Through our Unified API, we handle these complexities generically, allowing you to ship new API connectors as data-only operations.
Generic Execution Pipelines and JSONata
Truto operates on a declarative configuration model. There is zero integration-specific code in the Truto runtime. No if (provider === 'hubspot') statements exist in our proxy layer.
Instead, integration behavior is defined as data. We use JSONata expressions to map unified requests into provider-specific formats. For example, when you send a unified request to filter contacts by email, Truto's mapping engine automatically translates that into a HubSpot filterGroups array or a Salesforce SOQL WHERE clause.
flowchart LR
A[Unified Request<br>GET /unified/crm/contacts] --> B[Generic Mapping Engine]
B --> C{Integration<br>Config}
C -->|HubSpot| D[filterGroups +<br>cursor pagination]
C -->|Salesforce| E[SOQL WHERE +<br>OFFSET / nextRecordsUrl]
D --> F[Unified Response<br>same shape]
E --> FHere is a simplified example of how Truto uses JSONata to normalize a Salesforce response, automatically identifying custom fields by their __c suffix:
response_mapping: >-
response.{
"id": Id,
"first_name": FirstName,
"last_name": LastName,
"email_addresses": [{ "email": Email }],
"custom_fields": $sift($, function($v, $k) { $k ~> /__c$/i and $boolean($v) })
}This single expression maps standard fields while dynamically isolating any custom fields the specific Salesforce tenant has created, packaging them neatly into a custom_fields object in the unified response.
Standardizing API Rate Limits
Every API returns rate limit information differently. HubSpot uses X-HubSpot-RateLimit-Remaining, while others use RateLimit-Remaining or bury the limits in the response body.
Truto normalizes upstream rate limit info into standardized headers per the IETF specification:
ratelimit-limitratelimit-remainingratelimit-reset
Factual Note on Rate Limits: Truto is deliberately honest here. Truto does not swallow, retry, throttle, or apply backoff on rate limit errors automatically. When an upstream API returns an HTTP 429 or REQUEST_LIMIT_EXCEEDED, Truto passes that error cleanly to the caller along with the normalized IETF headers. The application owns the business context to decide whether to retry, drop, or degrade based on these predictable headers.
Dynamic Resource Routing
APIs often require different endpoints for different actions. For example, HubSpot has a standard endpoint for listing contacts, but requires a completely different search endpoint if you want to filter those contacts.
Truto supports dynamic resource routing natively. The platform evaluates the incoming request parameters and automatically routes the call to the correct upstream endpoint.
sequenceDiagram
participant Client
participant Truto Unified API
participant Truto JSONata Engine
participant Upstream CRM
Client->>Truto Unified API: GET /unified/crm/contacts?email=test@example.com
Truto Unified API->>Truto JSONata Engine: Evaluate query parameters
Truto JSONata Engine-->>Truto Unified API: Route to Search Endpoint
Truto Unified API->>Upstream CRM: POST /crm/v3/objects/contacts/search
Upstream CRM-->>Truto Unified API: Raw Provider Response
Truto Unified API->>Truto JSONata Engine: Apply Response Mapping
Truto JSONata Engine-->>Truto Unified API: Normalized Unified JSON
Truto Unified API-->>Client: 200 OK (Unified Schema)This architecture ensures that your engineers write code against one predictable REST API, while Truto handles the chaotic reality of third-party SaaS platforms in the background.
Strategic Wrap-Up and Next Steps
Building native CRM integrations requires far more than parsing a JSON response. To survive production environments, your architecture must account for strict burst rate limits, complex pagination models, and the reality of highly customized tenant schemas.
If your team is shipping a Salesforce or HubSpot integration this quarter, the priorities in order are:
- Implement a token bucket and Retry-After-aware client for HubSpot before you write a single mapper.
- Use Bulk API 2.0 for any Salesforce backfill over a few thousand records.
- Discover custom fields dynamically - do not hardcode field lists.
- Treat OAuth refresh as a separate failure domain with its own alerting.
- Decide build-vs-buy explicitly. If you are heading toward 5+ CRMs, the cost curve of maintaining per-vendor code paths gets brutal fast.
FAQ
- What is the HubSpot API rate limit for public apps in 2026?
- Public OAuth apps are limited to 110 requests per 10 seconds per installed account, and the CRM Search API has a separate cap of 5 requests per second shared across all object types. Daily quotas range from 250,000 (Free/Starter) to 1,000,000 (Enterprise).
- How do I avoid hitting Salesforce governor limits in an integration?
- Use Bulk API 2.0 for any operation over a few thousand records (each 10k-record batch counts as one call against the daily limit), keep synchronous requests under 20 seconds to avoid the concurrency cap, and use keyset pagination instead of OFFSET for large queries.
- How should I handle custom fields in Salesforce and HubSpot?
- Discover them at runtime. Call `/sobjects/Contact/describe` for Salesforce (filter matching the `__c` suffix) and `/crm/v3/properties/contacts` for HubSpot. Refresh on a TTL (24 hours works) and explicitly request them in your API calls rather than hardcoding fields.
- Should I retry on a 429 from HubSpot inside my application or rely on my unified API?
- Retries belong in your application, not in the integration layer. The application owns the business context to decide whether to retry, drop, or degrade. A platform like Truto passes 429s through cleanly and normalizes rate-limit headers to IETF standards, but does not retry on your behalf.