Skip to content

CRM Integration Implementation Recipes: Salesforce & HubSpot Architecture (2026)

Architectural blueprints for bidirectional HubSpot & Salesforce sync: token bucket rate limiting, webhook HMAC verification, field ownership rules, deletion handling, and a production go-live checklist.

Uday Gajavalli Uday Gajavalli · · 25 min read
CRM Integration Implementation Recipes: Salesforce & HubSpot Architecture (2026)

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 null for 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.

Bidirectional Sync Architecture: Real-Time vs Batch

Before diving into individual recipes, it helps to understand the full picture. The question most teams are really asking is: "How do I sync customer data bidirectionally between my app and HubSpot (or Salesforce) without things breaking?" The answer has three moving parts.

  1. Inbound path (CRM → Your App): The CRM pushes change events to your webhook endpoint. Your app processes them, maps them to your internal data model, and writes them to your database.
  2. Outbound path (Your App → CRM): When a record changes in your app, you push the update to the CRM's API. You need to prevent the resulting CRM webhook from echoing back and triggering a loop.
  3. Repair sync (periodic reconciliation): A scheduled batch job that sweeps updatedAt windows in both directions, catching anything the real-time path missed - dropped webhooks, failed writes, or drift that accumulates over time.

Real-time sync (webhooks) gives you sub-minute latency for most changes. Batch sync (polling lastmodifieddate windows) gives you completeness guarantees. You need both. If you skip the repair path, you are not building sync - you are building a demo.

flowchart LR
    subgraph RT[Real-Time Path]
        direction TB
        A1[CRM Webhook] -->|contact.propertyChange| B1[Your Webhook Endpoint]
        B1 --> C1[Map + Write to App DB]
        D1[App Record Changed] --> E1[Push to CRM API]
        E1 --> F1[Store Outbound Fingerprint]
    end
    subgraph RP[Repair Path]
        direction TB
        G1[Scheduled Job] --> H1[Poll CRM<br>updatedAt > last_sync]
        H1 --> I1[Diff + Reconcile]
        G1 --> J1[Poll App DB<br>updated_at > last_sync]
        J1 --> I1
    end

Preventing Infinite Loops with Fingerprints

The echo problem is the single biggest trap in bidirectional sync. When your app writes a contact update to HubSpot, HubSpot fires a contact.propertyChange webhook back to your endpoint. If you process that event naively, it writes back to your app, which triggers another outbound push, creating an infinite loop.

The standard prevention pattern:

  1. Before writing to HubSpot, compute a fingerprint (hash of the field values you are sending) and store it keyed by (record_id, direction).
  2. When an inbound webhook arrives, compute the fingerprint of the incoming data.
  3. If it matches a stored outbound fingerprint, it is an echo - acknowledge and skip.
  4. If it does not match, it is a genuine change from a HubSpot user - process it normally.

This pattern works regardless of the CRM. It is the same for Salesforce outbound messages, Pipedrive webhooks, or any other provider. The recipes below cover the specific implementation details for each part of this pipeline. For a deeper walkthrough of the full bidirectional architecture, see How to Architect a Bidirectional HubSpot Sync (Without Infinite Loops).

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 --> B

A 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.

Reading HubSpot Rate Limit Headers for Adaptive Throttling

The static token bucket above works, but you can do better. HubSpot returns rate limit state in every response via headers. Reading these headers lets your client adapt in real-time instead of guessing.

The relevant headers:

Header Meaning
X-HubSpot-RateLimit-Max Maximum requests allowed in the current window
X-HubSpot-RateLimit-Remaining Requests remaining before the limit resets
X-HubSpot-RateLimit-Interval-Milliseconds Duration of the current rate limit window

Here is an adaptive rate limiter that syncs its internal state from HubSpot's response headers on every call:

class AdaptiveHubSpotLimiter {
  private remaining = 110
  private windowMs = 10_000
  private windowStart = Date.now()
 
  updateFromHeaders(headers: Headers) {
    const rem = headers.get('X-HubSpot-RateLimit-Remaining')
    const max = headers.get('X-HubSpot-RateLimit-Max')
    const interval = headers.get('X-HubSpot-RateLimit-Interval-Milliseconds')
    if (rem !== null) this.remaining = parseInt(rem, 10)
    if (interval !== null) this.windowMs = parseInt(interval, 10)
  }
 
  async acquire(): Promise<void> {
    if (this.remaining > 5) { // keep a 5-request safety buffer
      this.remaining--
      return
    }
    const elapsed = Date.now() - this.windowStart
    const waitMs = Math.max(0, this.windowMs - elapsed) + 100
    await new Promise(r => setTimeout(r, waitMs))
    this.windowStart = Date.now()
    this.remaining = 110 // reset to burst capacity
  }
}
 
// Usage: wrap every outbound HubSpot call
async function hubspotFetch(
  url: string, opts: RequestInit, limiter: AdaptiveHubSpotLimiter
) {
  await limiter.acquire()
  const res = await fetch(url, opts)
  limiter.updateFromHeaders(res.headers)
  if (res.status === 429) {
    const retryAfter = parseInt(res.headers.get('Retry-After') ?? '10', 10)
    await new Promise(r => setTimeout(r, retryAfter * 1000 + Math.random() * 1000))
    return hubspotFetch(url, opts, limiter) // retry once
  }
  return res
}

The key detail: always keep a safety buffer of 5-10 remaining requests. If you drain the bucket to zero, concurrent requests in flight will all 429 simultaneously. The buffer gives you room to decelerate gracefully.

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.

Special Case: CRM Search API Pacing

The CRM Search API deserves its own rate limiter. As noted above, the standard burst limit (110 requests / 10 seconds for public apps) does not apply to search endpoints. The Search API is capped at approximately 4-5 requests per second, shared across all search endpoints at the account level - contacts, deals, companies, tickets, everything.

This has three practical implications:

  1. Separate your queues. Run search requests through a dedicated limiter with a 4 req/s ceiling, independent of your CRUD limiter. If you mix them in one queue, a burst of search calls will starve your other operations.
  2. Maximize page size. The Search API supports limit up to 200 records per response. At 4 requests per second with 200 records each, you get 800 records/second throughput. At the default of 10 records per page, you get 40 records/second. That is a 20x difference.
  3. Respect the 10,000-result cap. A single search query can only return 10,000 results total, even with pagination. If you need more, segment your queries by a range filter (e.g., createdate ranges) so each segment stays under 10,000.
// Dedicated search limiter - 4 req/s with 250ms spacing
class SearchApiLimiter {
  private lastCall = 0
  private readonly minGapMs = 250 // 4 req/s = 250ms between calls
 
  async acquire(): Promise<void> {
    const now = Date.now()
    const elapsed = now - this.lastCall
    if (elapsed < this.minGapMs) {
      await new Promise(r => setTimeout(r, this.minGapMs - elapsed))
    }
    this.lastCall = Date.now()
  }
}

If you use search for deduplication checks during writes (e.g., "does this email already exist?"), batch those checks before you start writing. Running interleaved search-then-write loops is the fastest way to exhaust your search budget.

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 2000

Notice 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.

  1. Create a Job: Send a POST request to define the object (e.g., Contact) and operation (e.g., query, insert, upsert).
  2. Upload Data: If inserting, upload CSV data to the job endpoint.
  3. Close the Job: Signal to Salesforce that the upload is complete.
  4. Poll for Status: Continuously poll the job status endpoint until it reads JobComplete. Do not poll aggressively - check status every 30-60 seconds.
  5. 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/describe

This 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(',')}`

Recipe 4: Webhook Subscription and HMAC Verification for Inbound Sync

The inbound leg of a bidirectional sync starts with CRM webhooks. When a contact, deal, or company changes in HubSpot, a webhook pushes the event to your endpoint. Getting this right requires correct subscription setup, signature verification, and idempotent processing.

Setting Up HubSpot Webhook Subscriptions

HubSpot supports webhook subscriptions through the Webhooks API (for developer apps) and through workflow actions (for Operations Hub / Data Hub). For a programmatic bidirectional sync, you want the Webhooks API approach.

Key setup steps:

  1. Register your app in the HubSpot developer portal with the required scopes (e.g., crm.objects.contacts.read for contact events).
  2. Configure a target URL - this must be an HTTPS endpoint that responds within 5 seconds.
  3. Create subscriptions for the event types you need: contact.creation, contact.propertyChange, contact.deletion, deal.creation, deal.propertyChange, deal.deletion, etc.
  4. Activate subscriptions - they start in an inactive state and must be explicitly activated.

HubSpot batches webhook events: a single POST to your endpoint can contain up to 100 events. Each event in the batch may relate to a different object or subscription type. Your handler must iterate through the array and process each event individually.

Info

Webhook configuration changes take up to 5 minutes to propagate in HubSpot due to internal caching. When deploying a new webhook URL, keep the old endpoint active for at least 5 minutes to avoid dropped events during the transition.

HubSpot v3 Signature Verification Checklist

Every inbound webhook from HubSpot includes an X-HubSpot-Signature-v3 header and an X-HubSpot-Request-Timestamp header. You must verify both before processing any payload.

The verification steps:

  1. Check the timestamp. Reject the request if X-HubSpot-Request-Timestamp is more than 5 minutes old. HubSpot timestamps are in milliseconds, not seconds - this is a common source of bugs.
  2. Build the signature input. Concatenate (UTF-8): requestMethod + requestUri + requestBody + timestamp. The request URI must match exactly what HubSpot sent - watch for reverse proxies or load balancers that rewrite URLs.
  3. Compute the HMAC. Use HMAC SHA-256 with your app's client secret (not an API key or access token) as the key.
  4. Base64-encode the HMAC result.
  5. Compare the computed value to the X-HubSpot-Signature-v3 header using constant-time comparison to prevent timing attacks.
  6. Verify the raw body. The HMAC is computed on the exact bytes HubSpot sends. If middleware parses or reformats the body before your verification code runs, the signature will not match.
import { createHmac, timingSafeEqual } from 'crypto'
 
function verifyHubSpotV3(
  method: string,
  uri: string,
  rawBody: string,
  timestamp: string,
  signature: string,
  clientSecret: string
): boolean {
  // Reject stale requests (timestamp is in milliseconds)
  const age = Date.now() - parseInt(timestamp, 10)
  if (age > 5 * 60 * 1000) return false
 
  const input = `${method}${uri}${rawBody}${timestamp}`
  const computed = createHmac('sha256', clientSecret)
    .update(input, 'utf8')
    .digest('base64')
 
  return timingSafeEqual(Buffer.from(computed), Buffer.from(signature))
}
Warning

Do not skip verification. Without HMAC checking, any attacker who discovers your webhook URL can inject fake CRM events into your sync pipeline. In a bidirectional system, a spoofed contact.propertyChange event would overwrite real data in your app and then propagate back to HubSpot.

Idempotent Event Processing

HubSpot does not guarantee exactly-once delivery. Webhooks are retried up to 10 times over 24 hours if your endpoint returns a non-2xx status. Your handler must be idempotent.

The simplest approach: store processed eventId values in a fast lookup (a database table with a unique index, or a cache with a 48-hour TTL). On each inbound event, check if the eventId has already been processed. If yes, respond 200 and skip. If no, process the event and record the eventId.

For propertyChange events specifically, compare the incoming propertyValue against your current record state rather than blindly overwriting. HubSpot does not guarantee event ordering, so a stale event could arrive after a newer one. Use the occurredAt timestamp within each event to determine the actual sequence.

Recipe 5: Deletion and Tombstone Strategies

Deletes are the most dangerous operation in a bidirectional sync. A missed deletion leaves ghost records. An overly aggressive deletion propagation can wipe data a user only meant to archive in one system.

CRM Deletion Events

HubSpot fires *.deletion webhook events (e.g., contact.deletion, deal.deletion) when a record is permanently deleted. The event payload contains the object ID but not the full record - the data is already gone by the time you receive the event.

Salesforce can notify you of deletions via outbound messages or by querying the getDeleted() endpoint on each object, which returns IDs deleted within a given time window.

Three strategies for handling deletions:

Strategy How It Works Best For
Hard delete Delete the record in your app immediately Compliance-driven systems (GDPR right to erasure)
Soft delete / tombstone Set a deleted_at timestamp and is_deleted = true flag; stop syncing the record but keep it for audit trails Most B2B SaaS apps
Archive only Mark as archived in your app; do not propagate the deletion back to the CRM Systems where accidental deletes are common

The recommended default is soft delete with tombstone. When you receive a contact.deletion event:

  1. Look up the record in your database by the CRM ID.
  2. If found, set deleted_at = now() and deleted_source = 'hubspot' (or 'salesforce').
  3. Stop including this record in outbound sync runs.
  4. Do not push a delete back to the CRM - the record is already gone there.

For the reverse direction (a user deletes a record in your app), you have a choice: propagate the delete to HubSpot via DELETE /crm/v3/objects/contacts/{id}, or simply stop syncing it. Propagating deletes is permanent and irrecoverable in HubSpot - there is no recycle bin API. Make this a configurable behavior per customer, not a hardcoded default.

Handling Bulk Deletes and GDPR

If a customer triggers a bulk deletion in HubSpot (e.g., a list cleanup), you may receive hundreds of contact.deletion events in a single batch webhook. Your handler needs to process these without overwhelming your database with individual DELETE queries. Batch your tombstone updates.

For GDPR data subject access requests, HubSpot provides a gdpr-delete endpoint. If you receive a deletion through that path, you must ensure full erasure in your system as well - tombstoning is not sufficient. Track the deletion source to distinguish compliance-mandated erasures from normal CRM housekeeping.

Recipe 6: Field Ownership and Conflict Resolution

In a bidirectional sync, two systems can update the same field on the same record at the same time. Without explicit ownership rules, you get last-write-wins chaos where changes silently overwrite each other.

The Decision Matrix

Before writing a single line of sync code, fill out this matrix for every field you plan to sync:

Field Owner Sync Direction Conflict Rule Example
Email HubSpot HubSpot → App Always use HubSpot Sales reps update emails in CRM
Lifecycle Stage HubSpot HubSpot → App Always use HubSpot Marketing owns the funnel
Plan Tier Your App App → HubSpot Always use App Billing system is source of truth
MRR Your App App → HubSpot Always use App Revenue data comes from your backend
Company Name Both Bidirectional Most recent updatedAt wins Either side may correct it
Phone Both Bidirectional HubSpot wins on conflict CRM is primary contact store

Three common conflict resolution strategies:

  1. Field-level ownership (recommended). Each field has exactly one authoritative source. plan_tier always comes from your app. lifecycle_stage always comes from HubSpot. No conflicts possible because writes only flow in one direction per field.
  2. Timestamp-based (last write wins). Compare updatedAt from both sides and take the newer value. Simple to implement but can lose data if clocks drift or events arrive out of order.
  3. Prefer-source-unless-blank. Use one system's value by default, but accept the other system's value if the preferred source is empty. Good for initial data enrichment, bad for ongoing sync.

Field-level ownership eliminates the most common class of sync bugs. It is more restrictive than last-write-wins, but it means you never have to debug "who overwrote this field and when" at 2am.

Implementing Field-Level Ownership in Code

type SyncDirection = 'hubspot_to_app' | 'app_to_hubspot' | 'bidirectional'
 
const fieldOwnership: Record<string, {
  direction: SyncDirection
  conflictRule: string
}> = {
  email:            { direction: 'hubspot_to_app', conflictRule: 'always_hubspot' },
  lifecycle_stage:  { direction: 'hubspot_to_app', conflictRule: 'always_hubspot' },
  plan_tier:        { direction: 'app_to_hubspot', conflictRule: 'always_app' },
  mrr:              { direction: 'app_to_hubspot', conflictRule: 'always_app' },
  company_name:     { direction: 'bidirectional',  conflictRule: 'latest_updated_at' },
}
 
function shouldApplyInboundChange(
  field: string, inboundValue: unknown, currentValue: unknown
): boolean {
  const rule = fieldOwnership[field]
  if (!rule) return false // unknown fields are not synced
  if (rule.direction === 'app_to_hubspot') return false
  if (rule.direction === 'hubspot_to_app') return true
  // bidirectional: compare timestamps or apply conflict rule
  return true
}

Store this configuration in a database or config file, not in code. When a customer asks you to change which system owns a field (and they will), you want to flip a config value, not ship a code change.

Monitoring, Metrics, and Alerting Playbook

A bidirectional sync that runs without monitoring is a ticking bomb. You will not notice dropped records, silent 429s, or accumulating drift until a customer reports it - usually weeks later.

Metrics to Track

Metric What It Measures Alert Threshold
crm.429_rate Percentage of outbound requests returning 429 > 5% over a 5-minute window
crm.search_429_rate 429 rate specifically for Search API > 2% (tighter because the budget is smaller)
webhook.inbound.count Inbound webhook events received per minute Drop to 0 for > 10 minutes (CRM delivery failure)
webhook.inbound.verification_failures HMAC signature mismatches Any non-zero count (potential spoofing or config error)
webhook.inbound.duplicate_rate Percentage of events already processed (by eventId) > 30% (indicates retries from slow acknowledgement)
sync.outbound.write_failures Failed writes to the CRM per hour > 10 per hour
sync.reconciliation.drift_count Records found out of sync during repair sweep > 1% of total synced records
sync.loop_detection Echo fingerprint matches per hour Sudden spike (fingerprint logic may be broken)
oauth.token_refresh_failures Failed OAuth token refreshes Any non-zero count (sync will halt entirely)

Reconciliation Job

Run a scheduled reconciliation job every 4-8 hours. The job:

  1. Fetches records updated since the last reconciliation window from both the CRM and your app.
  2. Compares field values for records that exist in both systems.
  3. Logs discrepancies and optionally auto-corrects them based on your field ownership rules.
  4. Reports sync.reconciliation.drift_count as a metric.

This is your safety net. If the real-time webhook path drops an event, the reconciliation sweep catches it within hours instead of weeks. If drift count trends upward over time, something in your real-time path is broken and needs investigation.

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 --> F

Here 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-limit
  • ratelimit-remaining
  • ratelimit-reset
Info

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.

Unified Webhooks for Bidirectional Sync

The inbound webhook handling described in Recipe 4 is exactly the kind of per-provider complexity that compounds across integrations. HubSpot uses X-HubSpot-Signature-v3 with HMAC SHA-256 over a specific concatenation. Salesforce uses its own verification scheme. Every CRM webhook has a different payload shape, different event type naming, and different retry behavior.

Truto's unified webhook layer normalizes all of this. Truto supports two inbound webhook ingestion patterns - account-specific and environment-integration fan-out - and uses JSONata-based configuration for provider-specific event normalization. The result is that your app receives a consistent event payload regardless of whether the source is HubSpot, Salesforce, or any other CRM.

Outbound delivery to your endpoint uses queue-backed processing with signed payloads. Truto signs every outbound webhook with HMAC-SHA256 and sends the signature in the X-Truto-Signature header, so your verification code is the same regardless of the upstream provider.

Go-Live Checklist

Before flipping your bidirectional CRM sync to production, walk through this list:

Authentication & Security

  • OAuth 2.0 flow tested with token refresh (confirm your app refreshes tokens before expiry)
  • Webhook endpoint is HTTPS-only
  • v3 HMAC signature verification active and rejecting stale timestamps (> 5 minutes)
  • Webhook secret stored encrypted at rest

Rate Limiting

  • Token bucket limiter in place for general API calls (110/10s for public apps, 190/10s for private Pro/Enterprise)
  • Separate rate limiter for CRM Search API (~4-5 req/s)
  • Retry-After header respected on 429 responses
  • Jitter added to retry delays

Sync Logic

  • Field ownership matrix documented and implemented for every synced field
  • Echo/loop prevention via outbound fingerprints verified with a live round-trip test
  • Deletion handling strategy configured (hard delete, soft delete, or archive)
  • Idempotency store in place for inbound webhook eventId deduplication
  • Custom properties discovered dynamically (not hardcoded)

Pagination & Bulk

  • Cursor-based pagination handling tested beyond 10,000 records
  • Batch endpoints used for bulk operations (up to 100 records per batch call)
  • Bulk API 2.0 (Salesforce) tested for backfills over 5,000 records

Monitoring

  • 429 rate alerting configured (> 5% threshold)
  • Webhook inbound silence alert (0 events for 10+ minutes)
  • HMAC verification failure alerts active
  • OAuth token refresh failure alerts active
  • Reconciliation job scheduled (every 4-8 hours)
  • Drift count baseline established

Operational Readiness

  • Dead letter queue configured for failed webhook deliveries
  • Manual reconciliation / full-resync trigger available
  • Runbook documenting how to pause sync, replay events, and investigate drift

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:

  1. Implement a token bucket and Retry-After-aware client for HubSpot before you write a single mapper.
  2. Use Bulk API 2.0 for any Salesforce backfill over a few thousand records.
  3. Discover custom fields dynamically - do not hardcode field lists.
  4. Treat OAuth refresh as a separate failure domain with its own alerting.
  5. 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.

More from our Blog